fix: Numerically-stable (1+r)^n-1 in computePaymentFactor (#7033)

This commit is contained in:
Vito Tumas
2026-05-07 21:02:09 +02:00
committed by GitHub
parent 4a9f72c73e
commit 4f8142fd10
5 changed files with 660 additions and 84 deletions

View File

@@ -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<std::pair<LoanPaymentParts, LoanProperties>, 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<Number, Number>
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,

View File

@@ -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<std::pair<LoanPaymentParts, LoanProperties>, 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 <class NumberProxy>
Expected<LoanPaymentParts, TER>
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,

View File

@@ -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,

View File

@@ -7,6 +7,7 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Units.h>
#include <cstdint>
@@ -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<TestCase>{
{
.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<AboveThreshold>{
{"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<BelowThreshold>{
// 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<Boundary>{
{"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();
}

View File

@@ -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<Issue>()).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();