diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 83ce8e5efa..b9711c4053 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -184,6 +184,7 @@ checkLoanGuards( LoanState computeTheoreticalLoanState( + Rules const& rules, Number const& periodicPayment, Number const& periodicRate, std::uint32_t const paymentRemaining, @@ -353,6 +354,7 @@ struct LoanStateDeltas Expected, TER> tryOverpayment( + Rules const& rules, Asset const& asset, std::int32_t loanScale, ExtendedPaymentComponents const& overpaymentComponents, @@ -363,11 +365,17 @@ tryOverpayment( TenthBips16 const managementFeeRate, beast::Journal j); -Number -computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining); +[[nodiscard]] Number +computePowerMinusOne(Number const& periodicRate, std::uint32_t paymentsRemaining); -Number -computePaymentFactor(Number const& periodicRate, std::uint32_t paymentsRemaining); +[[nodiscard]] Number +computePowerMinusOneHybrid(Number const& periodicRate, std::uint32_t paymentsRemaining); + +[[nodiscard]] Number +computePaymentFactor( + Rules const& rules, + Number const& periodicRate, + std::uint32_t paymentsRemaining); std::pair computeInterestAndFeeParts( @@ -378,12 +386,14 @@ computeInterestAndFeeParts( Number loanPeriodicPayment( + Rules const& rules, Number const& principalOutstanding, Number const& periodicRate, std::uint32_t paymentsRemaining); Number loanPrincipalFromPeriodicPayment( + Rules const& rules, Number const& periodicPayment, Number const& periodicRate, std::uint32_t paymentsRemaining); @@ -415,6 +425,7 @@ computeOverpaymentComponents( PaymentComponents computePaymentComponents( + Rules const& rules, Asset const& asset, std::int32_t scale, Number const& totalValueOutstanding, @@ -438,6 +449,7 @@ operator+(LoanState const& lhs, detail::LoanStateDeltas const& rhs); LoanProperties computeLoanProperties( + Rules const& rules, Asset const& asset, Number const& principalOutstanding, TenthBips32 interestRate, @@ -448,6 +460,7 @@ computeLoanProperties( LoanProperties computeLoanProperties( + Rules const& rules, Asset const& asset, Number const& principalOutstanding, Number const& periodicRate, diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 28b0c8976d..89d5ed8e35 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -110,14 +110,78 @@ LoanStateDeltas::nonNegative() managementFee = kNUM_ZERO; } -/* Computes (1 + periodicRate)^paymentsRemaining for amortization calculations. +/* Computes (1 + r)^n - 1 accurately even for near-zero r, where direct + * subtraction of `power(1 + r, n) - 1` suffers catastrophic cancellation. * - * Equation (5) from XLS-66 spec, Section A-2 Equation Glossary + * The binomial expansion gives + * (1 + r)^n - 1 = sum_{k=1}^{n} C(n,k) r^k + * = nr + C(n,2) r^2 + ... + r^n + * which is a sum of positive terms when r >= 0, avoiding cancellation. + * Each term is computed from the previous via + * term_{k+1} = term_k * r * (n - k) / (k + 1) + * + * The loop terminates early once the next term is below Number precision. */ Number -computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining) +computePowerMinusOne(Number const& periodicRate, std::uint32_t paymentsRemaining) { - return power(1 + periodicRate, paymentsRemaining); + XRPL_ASSERT_PARTS( + periodicRate >= beast::kZERO, + "xrpl::detail::computePowerMinusOne", + "periodicRate is non-negative"); + + if (paymentsRemaining == 0 || periodicRate == beast::kZERO) + return kNUM_ZERO; + + // k = 1 term: C(n, 1) * r = n * r + Number term = paymentsRemaining * periodicRate; + Number sum = term; + for (std::uint32_t k = 1; k < paymentsRemaining; ++k) + { + // term_{k+1} from term_k: multiply by r * (n - k) / (k + 1) + term = term * periodicRate * (paymentsRemaining - k) / (k + 1); + Number const next = sum + term; + // adding this term fell below Number's precision + if (next == sum) + break; + sum = next; + } + return sum; +} + +/* Hybrid evaluator of (1 + r)^n - 1. + * + * The closed-form `power(1 + r, n) - 1` loses sig digits to cancellation + * when `r * n` is small: the result `~r*n` sits well below the `1` that + * dominates `(1+r)^n`, so most of Number's stored precision is consumed + * by the leading `1`. + * + * A threshold of `1e-9` preserves the closed-form path for any rate the + * lending code actually sees in practice (fixtures at moderate rates are bit-exact), + * while routing the pathological near-zero regime through the binomial + * expansion where cancellation is severe. + */ +Number +computePowerMinusOneHybrid(Number const& periodicRate, std::uint32_t paymentsRemaining) +{ + XRPL_ASSERT_PARTS( + periodicRate >= beast::kZERO, + "xrpl::detail::computePowerMinusOneHybrid", + "periodicRate is non-negative"); + + if (paymentsRemaining == 0 || periodicRate == beast::kZERO) + return kNUM_ZERO; + + // Threshold 1e-9 retains ~10 sig digits of (1+r)^n - 1 against + // Number's 19-digit mantissa: the leading "1" of (1+r)^n consumes + // ~log10(1/(r*n)) digits before the subtraction. Above this point + // closed form is accurate and ~30-500x faster than the binomial + // expansion. + Number const cancellationThreshold{1, -9}; + if (paymentsRemaining * periodicRate >= cancellationThreshold) + return power(1 + periodicRate, paymentsRemaining) - 1; + + return computePowerMinusOne(periodicRate, paymentsRemaining); } /* Computes the payment factor used in standard amortization formulas. @@ -126,7 +190,10 @@ computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining) * Equation (6) from XLS-66 spec, Section A-2 Equation Glossary */ Number -computePaymentFactor(Number const& periodicRate, std::uint32_t paymentsRemaining) +computePaymentFactor( + Rules const& rules, + Number const& periodicRate, + std::uint32_t paymentsRemaining) { if (paymentsRemaining == 0) return kNUM_ZERO; @@ -135,7 +202,19 @@ computePaymentFactor(Number const& periodicRate, std::uint32_t paymentsRemaining if (periodicRate == beast::kZERO) return Number{1} / paymentsRemaining; - Number const raisedRate = computeRaisedRate(periodicRate, paymentsRemaining); + if (rules.enabled(fixCleanup3_2_0)) + { + Number const raisedRateMinusOne = + computePowerMinusOneHybrid(periodicRate, paymentsRemaining); + Number const raisedRate = 1 + raisedRateMinusOne; + + return (periodicRate * raisedRate) / raisedRateMinusOne; + } + + // Pre-fixCleanup3_2_0: direct subtraction `(1+r)^n - 1` suffers + // catastrophic cancellation at near-zero rates. Retained for + // amendment-gated bit-exact pre-fix behavior. + Number const raisedRate = power(1 + periodicRate, paymentsRemaining); return (periodicRate * raisedRate) / (raisedRate - 1); } @@ -147,6 +226,7 @@ computePaymentFactor(Number const& periodicRate, std::uint32_t paymentsRemaining */ Number loanPeriodicPayment( + Rules const& rules, Number const& principalOutstanding, Number const& periodicRate, std::uint32_t paymentsRemaining) @@ -158,7 +238,7 @@ loanPeriodicPayment( if (periodicRate == beast::kZERO) return principalOutstanding / paymentsRemaining; - return principalOutstanding * computePaymentFactor(periodicRate, paymentsRemaining); + return principalOutstanding * computePaymentFactor(rules, periodicRate, paymentsRemaining); } /* Reverse-calculates principal from periodic payment amount. @@ -168,6 +248,7 @@ loanPeriodicPayment( */ Number loanPrincipalFromPeriodicPayment( + Rules const& rules, Number const& periodicPayment, Number const& periodicRate, std::uint32_t paymentsRemaining) @@ -178,7 +259,7 @@ loanPrincipalFromPeriodicPayment( if (periodicRate == 0) return periodicPayment * paymentsRemaining; - return periodicPayment / computePaymentFactor(periodicRate, paymentsRemaining); + return periodicPayment / computePaymentFactor(rules, periodicRate, paymentsRemaining); } /* @@ -402,6 +483,7 @@ doPayment( */ Expected, TER> tryOverpayment( + Rules const& rules, Asset const& asset, std::int32_t loanScale, ExtendedPaymentComponents const& overpaymentComponents, @@ -414,7 +496,7 @@ tryOverpayment( { // Calculate what the loan state SHOULD be theoretically (at full precision) auto const theoreticalState = computeTheoreticalLoanState( - periodicPayment, periodicRate, paymentRemaining, managementFeeRate); + rules, periodicPayment, periodicRate, paymentRemaining, managementFeeRate); // Calculate the accumulated rounding errors. These need to be preserved // across the re-amortization to maintain consistency with the loan's @@ -432,6 +514,7 @@ tryOverpayment( // recalculates the periodic payment, total value, and management fees // for the remaining payment schedule. auto newLoanProperties = computeLoanProperties( + rules, asset, newTheoreticalPrincipal, periodicRate, @@ -445,9 +528,12 @@ tryOverpayment( // Calculate what the new loan state should be with the new periodic payment // including rounding errors - auto const newTheoreticalState = - computeTheoreticalLoanState( - newLoanProperties.periodicPayment, periodicRate, paymentRemaining, managementFeeRate) + + auto const newTheoreticalState = computeTheoreticalLoanState( + rules, + newLoanProperties.periodicPayment, + periodicRate, + paymentRemaining, + managementFeeRate) + errors; JLOG(j.debug()) << "new theoretical value: " << newTheoreticalState.valueOutstanding @@ -582,6 +668,7 @@ tryOverpayment( template Expected doOverpayment( + Rules const& rules, Asset const& asset, std::int32_t loanScale, ExtendedPaymentComponents const& overpaymentComponents, @@ -610,6 +697,7 @@ doOverpayment( // Attempt to re-amortize the loan with the overpayment applied. // This modifies the temporary copies, leaving the proxies unchanged. auto const ret = tryOverpayment( + rules, asset, loanScale, overpaymentComponents, @@ -823,8 +911,8 @@ computeFullPayment( // Calculate the theoretical principal based on the payment schedule. // This theoretical (unrounded) value is used to compute interest and // penalties accurately. - Number const theoreticalPrincipalOutstanding = - loanPrincipalFromPeriodicPayment(periodicPayment, periodicRate, paymentRemaining); + Number const theoreticalPrincipalOutstanding = loanPrincipalFromPeriodicPayment( + view.rules(), periodicPayment, periodicRate, paymentRemaining); // Full payment interest includes both accrued interest (time since last // payment) and prepayment penalty (for closing early). @@ -929,6 +1017,7 @@ PaymentComponents::trackedInterestPart() const */ PaymentComponents computePaymentComponents( + Rules const& rules, Asset const& asset, std::int32_t scale, Number const& totalValueOutstanding, @@ -966,7 +1055,7 @@ computePaymentComponents( // Calculate what the loan state SHOULD be after this payment (the target). // This is computed at full precision using the theoretical amortization. LoanState const trueTarget = computeTheoreticalLoanState( - periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate); + rules, periodicPayment, periodicRate, paymentRemaining - 1, managementFeeRate); // Round the target to the loan's scale to match how actual loan values // are stored. @@ -1379,6 +1468,7 @@ computeFullPaymentInterest( */ LoanState computeTheoreticalLoanState( + Rules const& rules, Number const& periodicPayment, Number const& periodicRate, std::uint32_t const paymentRemaining, @@ -1396,8 +1486,8 @@ computeTheoreticalLoanState( // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary Number const totalValueOutstanding = periodicPayment * paymentRemaining; - Number const principalOutstanding = - detail::loanPrincipalFromPeriodicPayment(periodicPayment, periodicRate, paymentRemaining); + Number const principalOutstanding = detail::loanPrincipalFromPeriodicPayment( + rules, periodicPayment, periodicRate, paymentRemaining); // Equation (31) from XLS-66 spec, Section A-2 Equation Glossary Number const interestOutstandingGross = totalValueOutstanding - principalOutstanding; @@ -1488,6 +1578,7 @@ computeManagementFee( */ LoanProperties computeLoanProperties( + Rules const& rules, Asset const& asset, Number const& principalOutstanding, TenthBips32 interestRate, @@ -1499,6 +1590,7 @@ computeLoanProperties( auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval); XRPL_ASSERT(interestRate == 0 || periodicRate > 0, "xrpl::computeLoanProperties : valid rate"); return computeLoanProperties( + rules, asset, principalOutstanding, periodicRate, @@ -1517,6 +1609,7 @@ computeLoanProperties( */ LoanProperties computeLoanProperties( + Rules const& rules, Asset const& asset, Number const& principalOutstanding, Number const& periodicRate, @@ -1525,7 +1618,7 @@ computeLoanProperties( std::int32_t minimumScale) { auto const periodicPayment = - detail::loanPeriodicPayment(principalOutstanding, periodicRate, paymentsRemaining); + detail::loanPeriodicPayment(rules, principalOutstanding, periodicRate, paymentsRemaining); auto const [totalValueOutstanding, loanScale] = [&]() { // only round up if there should be interest @@ -1573,10 +1666,10 @@ computeLoanProperties( // Compute the parts for the first payment. Ensure that the // principal payment will actually change the principal. auto const startingState = computeTheoreticalLoanState( - periodicPayment, periodicRate, paymentsRemaining, managementFeeRate); + rules, periodicPayment, periodicRate, paymentsRemaining, managementFeeRate); auto const firstPaymentState = computeTheoreticalLoanState( - periodicPayment, periodicRate, paymentsRemaining - 1, managementFeeRate); + rules, periodicPayment, periodicRate, paymentsRemaining - 1, managementFeeRate); // The unrounded principal part needs to be large enough to affect // the principal. What to do if not is left to the caller @@ -1733,6 +1826,7 @@ loanMakePayment( // payment is late or regular detail::ExtendedPaymentComponents periodic{ detail::computePaymentComponents( + view.rules(), asset, loanScale, totalValueOutstandingProxy, @@ -1841,6 +1935,7 @@ loanMakePayment( periodic = detail::ExtendedPaymentComponents{ detail::computePaymentComponents( + view.rules(), asset, loanScale, totalValueOutstandingProxy, @@ -1901,6 +1996,7 @@ loanMakePayment( // change auto periodicPaymentProxy = loan->at(sfPeriodicPayment); if (auto const overResult = detail::doOverpayment( + view.rules(), asset, loanScale, overpaymentComponents, diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index c921017d15..3f5bbfb1a3 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -418,6 +418,7 @@ LoanSet::doApply() auto const paymentTotal = tx[~sfPaymentTotal].value_or(kDEFAULT_PAYMENT_TOTAL); auto const properties = computeLoanProperties( + view.rules(), vaultAsset, principalRequested, interestRate, diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index cbfd9da884..96d0722732 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -17,63 +18,13 @@ namespace xrpl::test { class LendingHelpers_test : public beast::unit_test::Suite { - void - testComputeRaisedRate() - { - using namespace jtx; - using namespace xrpl::detail; - struct TestCase - { - std::string name; - Number periodicRate; - std::uint32_t paymentsRemaining; - Number expectedRaisedRate; - }; - - auto const testCases = std::vector{ - { - .name = "Zero payments remaining", - .periodicRate = Number{5, -2}, - .paymentsRemaining = 0, - .expectedRaisedRate = Number{1}, // (1 + r)^0 = 1 - }, - { - .name = "One payment remaining", - .periodicRate = Number{5, -2}, - .paymentsRemaining = 1, - .expectedRaisedRate = Number{105, -2}, - }, // 1.05^1 - { - .name = "Multiple payments remaining", - .periodicRate = Number{5, -2}, - .paymentsRemaining = 3, - .expectedRaisedRate = Number{1157625, -6}, - }, // 1.05^3 - { - .name = "Zero periodic rate", - .periodicRate = Number{0}, - .paymentsRemaining = 5, - .expectedRaisedRate = Number{1}, // (1 + 0)^5 = 1 - }}; - - for (auto const& tc : testCases) - { - testcase("computeRaisedRate: " + tc.name); - - auto const computedRaisedRate = - computeRaisedRate(tc.periodicRate, tc.paymentsRemaining); - BEAST_EXPECTS( - computedRaisedRate == tc.expectedRaisedRate, - "Raised rate mismatch: expected " + to_string(tc.expectedRaisedRate) + ", got " + - to_string(computedRaisedRate)); - } - } - void testComputePaymentFactor() { using namespace jtx; using namespace xrpl::detail; + Env const env{*this}; + auto const& rules = env.current()->rules(); struct TestCase { std::string name; @@ -114,7 +65,7 @@ class LendingHelpers_test : public beast::unit_test::Suite testcase("computePaymentFactor: " + tc.name); auto const computedPaymentFactor = - computePaymentFactor(tc.periodicRate, tc.paymentsRemaining); + computePaymentFactor(rules, tc.periodicRate, tc.paymentsRemaining); BEAST_EXPECTS( computedPaymentFactor == tc.expectedPaymentFactor, "Payment factor mismatch: expected " + to_string(tc.expectedPaymentFactor) + @@ -127,6 +78,8 @@ class LendingHelpers_test : public beast::unit_test::Suite { using namespace jtx; using namespace xrpl::detail; + Env const env{*this}; + auto const& rules = env.current()->rules(); struct TestCase { @@ -172,8 +125,8 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase("loanPeriodicPayment: " + tc.name); - auto const computedPeriodicPayment = - loanPeriodicPayment(tc.principalOutstanding, tc.periodicRate, tc.paymentsRemaining); + auto const computedPeriodicPayment = loanPeriodicPayment( + rules, tc.principalOutstanding, tc.periodicRate, tc.paymentsRemaining); BEAST_EXPECTS( computedPeriodicPayment == tc.expectedPeriodicPayment, "Periodic payment mismatch: expected " + to_string(tc.expectedPeriodicPayment) + @@ -186,6 +139,8 @@ class LendingHelpers_test : public beast::unit_test::Suite { using namespace jtx; using namespace xrpl::detail; + Env const env{*this}; + auto const& rules = env.current()->rules(); struct TestCase { @@ -232,7 +187,7 @@ class LendingHelpers_test : public beast::unit_test::Suite testcase("loanPrincipalFromPeriodicPayment: " + tc.name); auto const computedPrincipalOutstanding = loanPrincipalFromPeriodicPayment( - tc.periodicPayment, tc.periodicRate, tc.paymentsRemaining); + rules, tc.periodicPayment, tc.periodicRate, tc.paymentsRemaining); BEAST_EXPECTS( computedPrincipalOutstanding == tc.expectedPrincipalOutstanding, "Principal outstanding mismatch: expected " + @@ -241,6 +196,294 @@ class LendingHelpers_test : public beast::unit_test::Suite } } + void + testComputePowerMinusOne() + { + using namespace jtx; + using namespace xrpl::detail; + + // Edge cases. + { + testcase("computePowerMinusOne: zero rate returns zero"); + BEAST_EXPECT(computePowerMinusOne(0, 5) == 0); + } + { + testcase("computePowerMinusOne: zero paymentsRemaining returns zero"); + Number const fivePercent{5, -2}; + BEAST_EXPECT(computePowerMinusOne(fivePercent, 0) == 0); + } + // (1.05)^3 - 1 = 0.157625, computed independently by hand. + { + testcase("computePowerMinusOne: standard case (1.05)^3 - 1 = 0.157625"); + Number const r{5, -2}; + Number const expected{157625, -6}; + BEAST_EXPECT(computePowerMinusOne(r, 3) == expected); + } + // (1+1)^1 - 1 = 1. + { + testcase("computePowerMinusOne: r=1, n=1"); + BEAST_EXPECT(computePowerMinusOne(1, 1) == 1); + } + + // Property check at near-zero rate (the bug regime): for n=2 the + // mathematical identity is `(1+r)^2 - 1 = 2r + r^2`. We compute + // `2r + r^2` by direct multiplication in Number arithmetic — a + // path that doesn't share any code with the binomial loop — and + // assert the two paths agree. + { + testcase("computePowerMinusOne: near-zero rate matches independent 2r + r^2"); + // r = 1 TenthBips32 over 600s payment interval, computed + // independently below using xrpl::detail::loanPeriodicRate. + Number const r = loanPeriodicRate(TenthBips32{1}, 600); + Number const independentExpected = 2 * r + r * r; // (1+r)^2 - 1 + BEAST_EXPECT(computePowerMinusOne(r, 2) == independentExpected); + } + // Same property at n=3: (1+r)^3 - 1 = 3r + 3r^2 + r^3. + { + testcase("computePowerMinusOne: near-zero rate matches independent 3r + 3r^2 + r^3"); + Number const r = loanPeriodicRate(TenthBips32{1}, 600); + Number const independentExpected = 3 * r + 3 * r * r + r * r * r; + BEAST_EXPECT(computePowerMinusOne(r, 3) == independentExpected); + } + + // Larger-n stress test for the loop's early-termination logic. + // At very small r the binomial terms decrease by a factor of + // ~r*(n-k)/(k+1) per step, so even at n=1000 the loop should + // terminate in a small handful of iterations. Cross-check the + // result against the hybrid (which dispatches to this same + // binomial path when r*n < 1e-9). + { + testcase("computePowerMinusOne: large n, early termination matches hybrid output"); + // r*n = 1e-10 and 1e-12 — both clearly below the 1e-9 threshold. + Number const r1{1, -13}; + std::uint32_t const n1 = 1'000; + Number const r2{1, -15}; + std::uint32_t const n2 = 1'000; + BEAST_EXPECT(computePowerMinusOne(r1, n1) == computePowerMinusOneHybrid(r1, n1)); + BEAST_EXPECT(computePowerMinusOne(r2, n2) == computePowerMinusOneHybrid(r2, n2)); + BEAST_EXPECT(computePowerMinusOne(r1, n1) > 0); + BEAST_EXPECT(computePowerMinusOne(r2, n2) > 0); + } + } + + // Direct tests of `computePowerMinusOneHybrid`. Verifies the dispatcher + // picks the right branch and produces the right result on each side + // of the threshold. + void + testComputePowerMinusOneHybrid() + { + using namespace jtx; + using namespace xrpl::detail; + + // Above threshold (r * n >= 1e-9): hybrid must agree with the closed + // form `power(1+r, n) - 1` exactly (it is the closed form). + { + testcase("computePowerMinusOneHybrid: r*n >= 1e-9 uses closed form (bit-exact match)"); + + struct AboveThreshold + { + std::string name; + Number r; + std::uint32_t n; + }; + auto const cases = std::vector{ + {"r=5%, n=3", Number{5, -2}, 3}, + {"r=0.1%, n=1000", Number{1, -3}, 1'000}, + {"r=1e-7, n=100 (above threshold by 10x)", Number{1, -7}, 100}, + }; + for (auto const& tc : cases) + { + Number const closed = power(1 + tc.r, tc.n) - 1; + Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n); + BEAST_EXPECTS( + hybrid == closed, + tc.name + ": closed=" + to_string(closed) + ", hybrid=" + to_string(hybrid)); + } + } + + // Below threshold (r * n < 1e-9): hybrid must agree with + // `computePowerMinusOne` (the binomial expansion). At this regime + // the closed form is provably wrong (cancellation); we verify the + // dispatcher routes to the binomial path. + { + testcase( + "computePowerMinusOneHybrid: r*n < 1e-9 uses binomial expansion (bit-exact match)"); + + struct BelowThreshold + { + std::string name; + Number r; + std::uint32_t n; + }; + auto const cases = std::vector{ + // bug regime: r = 1 TenthBips32 over 600s payment interval + // → r ≈ 1.9e-10, r*n ≈ 3.8e-10 < 1e-9. + {"bug regime: r~1.9e-10, n=2", loanPeriodicRate(TenthBips32{1}, 600), 2}, + {"r=1e-12, n=100", Number{1, -12}, 100}, + }; + for (auto const& tc : cases) + { + Number const binom = computePowerMinusOne(tc.r, tc.n); + Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n); + BEAST_EXPECTS( + hybrid == binom, + tc.name + ": binom=" + to_string(binom) + ", hybrid=" + to_string(hybrid)); + } + } + + // Edge cases. + { + testcase("computePowerMinusOneHybrid: edge cases"); + Number const fivePercent{5, -2}; + BEAST_EXPECT(computePowerMinusOneHybrid(0, 100) == 0); + BEAST_EXPECT(computePowerMinusOneHybrid(fivePercent, 0) == 0); + BEAST_EXPECT(computePowerMinusOneHybrid(0, 0) == 0); + } + + // Threshold boundary: r*n = 1e-9 exactly. Hybrid uses `>=` against + // the threshold, so this case must take the closed-form branch. + // We also verify that the binomial path agrees with the closed + // form to high precision at this crossover — confirming the + // threshold is placed where both paths give "adequate" answers. + { + testcase("computePowerMinusOneHybrid: threshold boundary r*n = 1e-9"); + + // Construct exactly r*n = 1e-9 with two distinct (r, n) pairs. + struct Boundary + { + std::string name; + Number r; + std::uint32_t n; + }; + auto const cases = std::vector{ + {"r=1e-9, n=1", Number{1, -9}, 1}, + {"r=1e-12, n=1000", Number{1, -12}, 1'000}, + }; + + for (auto const& tc : cases) + { + Number const closed = power(1 + tc.r, tc.n) - 1; + Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n); + Number const binom = computePowerMinusOne(tc.r, tc.n); + + // At exact threshold, hybrid must take closed-form path: + // bit-exact match with closed. + BEAST_EXPECTS( + hybrid == closed, + tc.name + ": hybrid should equal closed at threshold; got hybrid=" + + to_string(hybrid) + ", closed=" + to_string(closed)); + + // Closed-form and binomial must agree at the threshold to + // within Number's post-subtraction precision (~10 sig + // digits of `r*n = 1e-9`, i.e. ~1e-19 absolute error). + Number const tolerance{1, -18}; + Number const diff = abs(closed - binom); + BEAST_EXPECTS( + diff < tolerance, + tc.name + ": closed and binomial diverge at threshold by " + to_string(diff)); + } + } + } + + // Regression: at near-zero rate, `loanPrincipalFromPeriodicPayment` + // must satisfy `principal <= periodicPayment * paymentsRemaining` for + // any non-negative rate. The naive closed-form path violated this + // bound due to catastrophic cancellation in `(1+r)^n - 1`. + void + testLoanPrincipalFromPeriodicPaymentNearZeroRate() + { + testcase("loanPrincipalFromPeriodicPayment: principal <= payment*n at near-zero rate"); + using namespace jtx; + using namespace xrpl::detail; + Env const env{*this}; + auto const& rules = env.current()->rules(); + + // Inputs from the bug reproduction in Loan_test.cpp: + // InterestRate = 1 TenthBips32 (0.001 % per year), + // PaymentInterval = 600 s, principal = 100, 3 payments. + // periodicRate is ~1.9e-10. + auto const periodicRate = loanPeriodicRate(TenthBips32{1}, 600); + auto const periodicPayment = loanPeriodicPayment(rules, 100, periodicRate, 3); + + for (auto const n : {3u, 2u, 1u}) + { + auto const computed = + loanPrincipalFromPeriodicPayment(rules, periodicPayment, periodicRate, n); + auto const upperBound = periodicPayment * Number{n}; + BEAST_EXPECTS( + computed <= upperBound, + "n=" + std::to_string(n) + ": payment*n=" + to_string(upperBound) + + ", principal=" + to_string(computed)); + } + } + + // Regression: `computeTheoreticalLoanState` must produce a non-negative + // `interestDue` for any non-negative rate. Pre-fix, near-zero rates + // produced a negative `interestDue` because `(1+r)^n - 1` lost most of + // its precision to cancellation. + void + testComputeTheoreticalLoanStateNearZeroRate() + { + testcase("computeTheoreticalLoanState: non-negative interestDue at near-zero rate"); + using namespace jtx; + using namespace xrpl::detail; + Env const env{*this}; + auto const& rules = env.current()->rules(); + + auto const periodicRate = loanPeriodicRate(TenthBips32{1}, 600); + auto const periodicPayment = loanPeriodicPayment(rules, 100, periodicRate, 3); + + auto const state = + computeTheoreticalLoanState(rules, periodicPayment, periodicRate, 2, TenthBips32{0}); + + BEAST_EXPECT(state.principalOutstanding <= state.valueOutstanding); + BEAST_EXPECT(state.interestDue >= 0); + BEAST_EXPECT(state.managementFeeDue == 0); + } + + // Direct gating proof: at near-zero rate, `computePaymentFactor` must + // return different values with `fixCleanup3_2_0` disabled vs enabled. + // The enabled path agrees with an independent polynomial reference; + // the disabled path diverges by a measurable amount due to the + // catastrophic cancellation in `(1+r)^n - 1`. + void + testComputePaymentFactorNearZeroRate() + { + testcase("computePaymentFactor: near-zero rate, amendment disabled vs enabled"); + using namespace jtx; + using namespace xrpl::detail; + + Number const r = loanPeriodicRate(TenthBips32{1}, 600); + std::uint32_t const n = 3; + + // Independent reference: expand F(r,3) = r*(1+r)^3/((1+r)^3-1) + // algebraically for n=3, dividing numerator and denominator by r: + // F(r,3) = (1 + 3r + 3r^2 + r^3) / (3 + 3r + r^2) + // No power(), no binomial series — pure polynomial arithmetic in + // Number. + Number const reference = (1 + 3 * r + 3 * r * r + r * r * r) / (3 + 3 * r + r * r); + + // Pre-fix: closed form power(1+r, n) - 1 suffers catastrophic + // cancellation when r*n ~ 5.7e-10. + Env const envBug{*this, testableAmendments() - fixCleanup3_2_0}; + Number const buggyFactor = computePaymentFactor(envBug.current()->rules(), r, n); + + // Post-fix: hybrid binomial path avoids cancellation. + Env const envFix{*this}; + Number const correctFactor = computePaymentFactor(envFix.current()->rules(), r, n); + + // The amendment must change the computed factor in this regime. + BEAST_EXPECT(buggyFactor != correctFactor); + + // The fixed factor must agree with the polynomial reference to + // within a few ULPs of Number's 19-digit precision. + BEAST_EXPECT(abs(correctFactor - reference) < Number(1, -15)); + + // The buggy factor must diverge from the reference by a measurable + // amount — empirically ~1e-10 in this regime. + BEAST_EXPECT(abs(buggyFactor - reference) > Number(1, -12)); + } + void testComputeOverpaymentComponents() { @@ -606,6 +849,7 @@ class LendingHelpers_test : public beast::unit_test::Suite asset, loanScale, overpaymentAmount, TenthBips32(0), TenthBips32(0), managementFeeRate); auto const loanProperties = computeLoanProperties( + env.current()->rules(), asset, loanPrincipal, loanInterestRate, @@ -615,6 +859,7 @@ class LendingHelpers_test : public beast::unit_test::Suite loanScale); auto const ret = tryOverpayment( + env.current()->rules(), asset, loanScale, overpaymentComponents, @@ -697,6 +942,7 @@ class LendingHelpers_test : public beast::unit_test::Suite managementFeeRate); auto const loanProperties = computeLoanProperties( + env.current()->rules(), asset, loanPrincipal, loanInterestRate, @@ -706,6 +952,7 @@ class LendingHelpers_test : public beast::unit_test::Suite loanScale); auto const ret = tryOverpayment( + env.current()->rules(), asset, loanScale, overpaymentComponents, @@ -790,6 +1037,7 @@ class LendingHelpers_test : public beast::unit_test::Suite managementFeeRate); auto const loanProperties = computeLoanProperties( + env.current()->rules(), asset, loanPrincipal, loanInterestRate, @@ -799,6 +1047,7 @@ class LendingHelpers_test : public beast::unit_test::Suite loanScale); auto const ret = tryOverpayment( + env.current()->rules(), asset, loanScale, overpaymentComponents, @@ -889,6 +1138,7 @@ class LendingHelpers_test : public beast::unit_test::Suite managementFeeRate); auto const loanProperties = computeLoanProperties( + env.current()->rules(), asset, loanPrincipal, loanInterestRate, @@ -898,6 +1148,7 @@ class LendingHelpers_test : public beast::unit_test::Suite loanScale); auto const ret = tryOverpayment( + env.current()->rules(), asset, loanScale, overpaymentComponents, @@ -996,6 +1247,7 @@ class LendingHelpers_test : public beast::unit_test::Suite managementFeeRate); auto const loanProperties = computeLoanProperties( + env.current()->rules(), asset, loanPrincipal, loanInterestRate, @@ -1005,6 +1257,7 @@ class LendingHelpers_test : public beast::unit_test::Suite loanScale); auto const ret = tryOverpayment( + env.current()->rules(), asset, loanScale, overpaymentComponents, @@ -1103,6 +1356,7 @@ class LendingHelpers_test : public beast::unit_test::Suite managementFeeRate); auto const loanProperties = computeLoanProperties( + env.current()->rules(), asset, loanPrincipal, loanInterestRate, @@ -1112,6 +1366,7 @@ class LendingHelpers_test : public beast::unit_test::Suite loanScale); auto const ret = tryOverpayment( + env.current()->rules(), asset, loanScale, overpaymentComponents, @@ -1199,8 +1454,12 @@ public: testLoanLatePaymentInterest(); testLoanPeriodicPayment(); testLoanPrincipalFromPeriodicPayment(); - testComputeRaisedRate(); + testLoanPrincipalFromPeriodicPaymentNearZeroRate(); testComputePaymentFactor(); + testComputePowerMinusOne(); + testComputePowerMinusOneHybrid(); + testComputeTheoreticalLoanStateNearZeroRate(); + testComputePaymentFactorNearZeroRate(); testComputeOverpaymentComponents(); testComputeInterestAndFeeParts(); } diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 713168a4ee..47c07d240d 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -713,6 +713,7 @@ protected: auto const total = loanParams.payTotal.value_or(LoanSet::kDEFAULT_PAYMENT_TOTAL); auto const feeRate = brokerParams.managementFeeRate; auto const props = computeLoanProperties( + env.current()->rules(), asset, principal, interest, @@ -918,6 +919,7 @@ protected: state.totalValue, state.principalOutstanding, state.managementFeeOutstanding); { auto const raw = computeTheoreticalLoanState( + env.current()->rules(), state.periodicPayment, periodicRate, state.paymentRemaining, @@ -961,6 +963,7 @@ protected: std::size_t totalPaymentsMade = 0; xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), state.periodicPayment, periodicRate, state.paymentRemaining, @@ -990,6 +993,7 @@ protected: validateBorrowerBalance(); // Compute the expected principal amount auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), broker.asset.raw(), state.loanScale, state.totalValue, @@ -1010,6 +1014,7 @@ protected: paymentComponents.trackedManagementFeeDelta); xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), state.periodicPayment, periodicRate, state.paymentRemaining - 1, @@ -1407,6 +1412,7 @@ protected: auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); auto const loanProperties = computeLoanProperties( + env.current()->rules(), broker.asset.raw(), state.principalOutstanding, state.interestRate, @@ -2540,6 +2546,7 @@ protected: { auto const raw = computeTheoreticalLoanState( + env.current()->rules(), state.periodicPayment, periodicRate, state.paymentRemaining, @@ -2577,6 +2584,7 @@ protected: std::size_t totalPaymentsMade = 0; xrpl::LoanState currentTrueState = computeTheoreticalLoanState( + env.current()->rules(), state.periodicPayment, periodicRate, state.paymentRemaining, @@ -2586,6 +2594,7 @@ protected: { // Compute the expected principal amount auto const paymentComponents = xrpl::detail::computePaymentComponents( + env.current()->rules(), broker.asset.raw(), state.loanScale, state.totalValue, @@ -2603,6 +2612,7 @@ protected: ", periodic payment: " + to_string(roundedPeriodicPayment)); xrpl::LoanState const nextTrueState = computeTheoreticalLoanState( + env.current()->rules(), state.periodicPayment, periodicRate, state.paymentRemaining - 1, @@ -5586,7 +5596,11 @@ protected: auto const periodicRate = loanPeriodicRate(interestRateValue, state.paymentInterval); auto const rawLoanState = computeTheoreticalLoanState( - state.periodicPayment, periodicRate, state.paymentRemaining, managementFeeRate); + env.current()->rules(), + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRate); auto const parentCloseTime = env.current()->parentCloseTime(); auto const startDateSeconds = @@ -5815,6 +5829,7 @@ protected: auto state = getCurrentState(env, broker, loanKeylet); Number const periodicRate = loanPeriodicRate(state.interestRate, state.paymentInterval); auto const components = xrpl::detail::computePaymentComponents( + env.current()->rules(), asset.raw(), state.loanScale, state.totalValue, @@ -5848,7 +5863,10 @@ protected: // schedule auto const fullPaymentInterest = computeFullPaymentInterest( xrpl::detail::loanPrincipalFromPeriodicPayment( - after.periodicPayment, periodicRate2, after.paymentRemaining), + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), periodicRate2, env.current()->parentCloseTime(), after.paymentInterval, @@ -5881,7 +5899,10 @@ protected: auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); auto const fullPaymentInterestClamped = computeFullPaymentInterest( xrpl::detail::loanPrincipalFromPeriodicPayment( - after.periodicPayment, periodicRate2, after.paymentRemaining), + env.current()->rules(), + after.periodicPayment, + periodicRate2, + after.paymentRemaining), periodicRate2, env.current()->parentCloseTime(), after.paymentInterval, @@ -7284,6 +7305,188 @@ protected: attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); } + // A near-zero interest rate on a 100 USD loan + // produces total interest of ~6 units at loanScale -9. Numerical error + // in the amortization formula pushes the theoretical principal above + // the theoretical value, producing a negative theoretical interest. + // The payment delta then exceeds the actual outstanding interest, + // violating XRPL_ASSERT_PARTS in computePaymentComponents. + void + testBugInterestDueDeltaCrash() + { + testcase("bug: LoanPay asserts 'interest due delta' on near-zero rate"); + + using namespace jtx; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + env(trust(lender, iouAsset(1'000'000'000))); + env(trust(borrower, iouAsset(1'000'000'000))); + env(pay(issuer, lender, iouAsset(5'000'000))); + env(pay(issuer, borrower, iouAsset(5'000'000))); + env.close(); + + BrokerParameters const brokerParams{ + .vaultDeposit = 1'000'000, + .debtMax = 1'000'000, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + using namespace loan; + + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{100}; + + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ObjectValue)); + + createJson["InterestRate"] = 1; // minimum non-zero rate + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const keylet = keylet::loan(broker.brokerID, loanSequence); + + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + // For principal=100, n=3 the amortization schedule produces a + // periodic payment ≈ 33.33 USD. We pay 35 USD, which is more than + // one period's worth — enough for the LoanPay path to enter + // computePaymentComponents and reach the assertion that fires + // when the bug is present. With the fix, the tx applies cleanly. + env(pay(borrower, keylet.key, iouAsset(35)), Ter(tesSUCCESS)); + env.close(); + } + + // Integration test: full lifecycle of a $1B loan in the bug regime. + // Verifies that the vault collects the economically-correct interest + // income and that conservation holds at the trust-line level. + // + // Pre-fix (closed-form `power(1+r, n) - 1`): vault collected only + // ~$0.058 per $1B due to cancellation of `(1+r)^n - 1` at r*n ~ 5.7e-10. + // Post-fix (hybrid binomial path): vault collects ~$0.38 per $1B, + // matching the value computed independently with arbitrary-precision + // Decimal arithmetic. + void + testFullLifecycleVaultPnLNearZeroRate() + { + testcase("integration: full loan lifecycle, vault interest at near-zero rate"); + + using namespace jtx; + using namespace jtx::loan; + using namespace std::chrono_literals; + Env env(*this, all_); + + Account const issuer{"issuer"}; + Account const lender{"lender"}; + Account const borrower{"borrower"}; + + env.fund(XRP(1'000'000), issuer, lender, borrower); + env.close(); + env(fset(issuer, asfDefaultRipple)); + env.close(); + + PrettyAsset const iouAsset = issuer["USD"]; + STAmount const trustLimit{iouAsset.raw(), Number{1, 17}}; + env(trust(lender, trustLimit)); + env(trust(borrower, trustLimit)); + env.close(); + env(pay(issuer, lender, iouAsset(5'000'000'000LL))); + env(pay(issuer, borrower, iouAsset(5'000'000'000LL))); + env.close(); + + auto usdBalance = [&](Account const& a) { + return env.balance(a, iouAsset.raw().get()).value(); + }; + STAmount const borrowerStartBal = usdBalance(borrower); + + BrokerParameters const brokerParams{ + .vaultDeposit = Number{2, 9}, + .debtMax = Number{0}, + .coverRateMin = TenthBips32{0}, + .coverDeposit = 0, + .managementFeeRate = TenthBips16{0}, + .coverRateLiquidation = TenthBips32{0}}; + BrokerInfo const broker{createVaultAndBroker(env, iouAsset, lender, brokerParams)}; + + auto const vaultBefore = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultBefore); + Number const vaultAvailableBefore = vaultBefore->at(sfAssetsAvailable); + + // Loan: $1B principal, 3 payments, 600s interval, rate=1 TenthBips32. + auto const loanSetFee = Fee(env.current()->fees().base * 2); + Number const principalRequest{1, 9}; + auto createJson = env.json( + set(borrower, broker.brokerID, principalRequest), + Fee(loanSetFee), + Json(sfCounterpartySignature, json::ObjectValue)); + createJson["InterestRate"] = 1; + createJson["PaymentTotal"] = 3; + createJson["PaymentInterval"] = 600; + + auto const brokerStateBefore = env.le(keylet::loanbroker(broker.brokerID)); + auto const loanSequence = brokerStateBefore->at(sfLoanSequence); + auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence); + createJson = env.json(createJson, Sig(sfCounterpartySignature, lender)); + env(createJson, Ter(tesSUCCESS)); + env.close(); + + auto const loanSle = env.le(loanKeylet); + BEAST_EXPECT(loanSle); + Number const expectedTotalInterest = + loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfPrincipalOutstanding); + + env(pay(borrower, loanKeylet.key, iouAsset(1'500'000'000LL)), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfter = env.le(broker.vaultKeylet()); + Number const vaultAvailableAfter = vaultAfter->at(sfAssetsAvailable); + Number const vaultGain = vaultAvailableAfter - vaultAvailableBefore; + + STAmount const borrowerEndBal = usdBalance(borrower); + STAmount const borrowerNetOut = borrowerStartBal - borrowerEndBal; + + // Self-consistency: vault gained exactly the expected interest + // computed at LoanSet, and the borrower's outflow matches. + BEAST_EXPECT(vaultGain == expectedTotalInterest); + BEAST_EXPECT(Number(borrowerNetOut) == expectedTotalInterest); + + // Mathematical correctness: the total interest for this loan + // configuration is 0.38051750382930729983, calculated + // independently using 50-digit Decimal arithmetic (no + // cancellation possible at that precision). At Number's 19-digit + // mantissa this rounds to 0.38051750382930729 — the literal + // below. The vault's actual gain must agree to within + // sub-microcent precision. + Number const decimalReference{38051750382930729LL, -17}; + Number const tolerance{1, -6}; // 1e-6 USD = sub-microcent + Number const error = abs(vaultGain - decimalReference); + BEAST_EXPECTS( + error < tolerance, + "vault gain " + to_string(vaultGain) + " differs from Decimal reference " + + to_string(decimalReference) + " by " + to_string(error) + " — exceeds tolerance " + + to_string(tolerance)); + } + public: void run() override @@ -7292,6 +7495,10 @@ public: testLoanPayLateFullPaymentBypassesPenalties(); testLoanCoverMinimumRoundingExploit(); #endif + + testBugInterestDueDeltaCrash(); + testFullLifecycleVaultPnLNearZeroRate(); + testWithdrawReflectsUnrealizedLoss(); testInvalidLoanSet();