mirror of
https://github.com/XRPLF/rippled.git
synced 2026-04-29 15:37:57 +00:00
Compare commits
3 Commits
bthomee/no
...
tapanito/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c2be05cdc | ||
|
|
de7a14a28b | ||
|
|
26f82a2a16 |
@@ -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,
|
||||
|
||||
@@ -110,14 +110,90 @@ LoanStateDeltas::nonNegative()
|
||||
managementFee = numZero;
|
||||
}
|
||||
|
||||
/* 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.
|
||||
*
|
||||
* Precondition: r >= 0. Negative rates would produce alternating-sign
|
||||
* terms; the early-termination check (next == sum) could exit before the
|
||||
* series stabilizes, yielding an incorrect result. The lending protocol
|
||||
* derives rates from `TenthBips32` (unsigned), so this is always met in
|
||||
* production paths.
|
||||
*/
|
||||
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::zero,
|
||||
"xrpl::detail::computePowerMinusOne",
|
||||
"periodicRate is non-negative");
|
||||
|
||||
if (paymentsRemaining == 0 || periodicRate == beast::zero)
|
||||
return numZero;
|
||||
|
||||
// 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`. The lending code path uses Number's large-mantissa
|
||||
* range (mantissa in `[10^18, 10^19)` — 19 significant digits).
|
||||
*
|
||||
* Repeated squaring in `power(...)` contributes roughly `log2(n)` ULPs of
|
||||
* error at the scale of `(1+r)^n` (~1 for small `r*n`), so the absolute
|
||||
* error after the subtraction is around `log2(n) * 1e-18`. To retain at
|
||||
* least ~10 significant digits of `(1+r)^n - 1`, we need
|
||||
* `r*n >= log2(n) * 1e-18 * 1e9 ~ 1e-9` across realistic `n`. 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::zero,
|
||||
"xrpl::detail::computePowerMinusOneHybrid",
|
||||
"periodicRate is non-negative");
|
||||
|
||||
if (paymentsRemaining == 0 || periodicRate == beast::zero)
|
||||
return numZero;
|
||||
|
||||
// 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 +202,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 numZero;
|
||||
@@ -135,7 +214,19 @@ computePaymentFactor(Number const& periodicRate, std::uint32_t paymentsRemaining
|
||||
if (periodicRate == beast::zero)
|
||||
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 +238,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 +250,7 @@ loanPeriodicPayment(
|
||||
if (periodicRate == beast::zero)
|
||||
return principalOutstanding / paymentsRemaining;
|
||||
|
||||
return principalOutstanding * computePaymentFactor(periodicRate, paymentsRemaining);
|
||||
return principalOutstanding * computePaymentFactor(rules, periodicRate, paymentsRemaining);
|
||||
}
|
||||
|
||||
/* Reverse-calculates principal from periodic payment amount.
|
||||
@@ -168,6 +260,7 @@ loanPeriodicPayment(
|
||||
*/
|
||||
Number
|
||||
loanPrincipalFromPeriodicPayment(
|
||||
Rules const& rules,
|
||||
Number const& periodicPayment,
|
||||
Number const& periodicRate,
|
||||
std::uint32_t paymentsRemaining)
|
||||
@@ -178,7 +271,7 @@ loanPrincipalFromPeriodicPayment(
|
||||
if (periodicRate == 0)
|
||||
return periodicPayment * paymentsRemaining;
|
||||
|
||||
return periodicPayment / computePaymentFactor(periodicRate, paymentsRemaining);
|
||||
return periodicPayment / computePaymentFactor(rules, periodicRate, paymentsRemaining);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -402,6 +495,7 @@ doPayment(
|
||||
*/
|
||||
Expected<std::pair<LoanPaymentParts, LoanProperties>, TER>
|
||||
tryOverpayment(
|
||||
Rules const& rules,
|
||||
Asset const& asset,
|
||||
std::int32_t loanScale,
|
||||
ExtendedPaymentComponents const& overpaymentComponents,
|
||||
@@ -414,7 +508,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 +526,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 +540,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
|
||||
@@ -578,6 +676,7 @@ tryOverpayment(
|
||||
template <class NumberProxy>
|
||||
Expected<LoanPaymentParts, TER>
|
||||
doOverpayment(
|
||||
Rules const& rules,
|
||||
Asset const& asset,
|
||||
std::int32_t loanScale,
|
||||
ExtendedPaymentComponents const& overpaymentComponents,
|
||||
@@ -606,6 +705,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,
|
||||
@@ -819,8 +919,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).
|
||||
@@ -924,6 +1024,7 @@ PaymentComponents::trackedInterestPart() const
|
||||
*/
|
||||
PaymentComponents
|
||||
computePaymentComponents(
|
||||
Rules const& rules,
|
||||
Asset const& asset,
|
||||
std::int32_t scale,
|
||||
Number const& totalValueOutstanding,
|
||||
@@ -961,7 +1062,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.
|
||||
@@ -1370,6 +1471,7 @@ computeFullPaymentInterest(
|
||||
*/
|
||||
LoanState
|
||||
computeTheoreticalLoanState(
|
||||
Rules const& rules,
|
||||
Number const& periodicPayment,
|
||||
Number const& periodicRate,
|
||||
std::uint32_t const paymentRemaining,
|
||||
@@ -1387,8 +1489,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;
|
||||
@@ -1478,6 +1580,7 @@ computeManagementFee(
|
||||
*/
|
||||
LoanProperties
|
||||
computeLoanProperties(
|
||||
Rules const& rules,
|
||||
Asset const& asset,
|
||||
Number const& principalOutstanding,
|
||||
TenthBips32 interestRate,
|
||||
@@ -1489,6 +1592,7 @@ computeLoanProperties(
|
||||
auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval);
|
||||
XRPL_ASSERT(interestRate == 0 || periodicRate > 0, "xrpl::computeLoanProperties : valid rate");
|
||||
return computeLoanProperties(
|
||||
rules,
|
||||
asset,
|
||||
principalOutstanding,
|
||||
periodicRate,
|
||||
@@ -1507,6 +1611,7 @@ computeLoanProperties(
|
||||
*/
|
||||
LoanProperties
|
||||
computeLoanProperties(
|
||||
Rules const& rules,
|
||||
Asset const& asset,
|
||||
Number const& principalOutstanding,
|
||||
Number const& periodicRate,
|
||||
@@ -1515,7 +1620,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
|
||||
@@ -1562,10 +1667,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
|
||||
@@ -1722,6 +1827,7 @@ loanMakePayment(
|
||||
// payment is late or regular
|
||||
detail::ExtendedPaymentComponents periodic{
|
||||
detail::computePaymentComponents(
|
||||
view.rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
totalValueOutstandingProxy,
|
||||
@@ -1830,6 +1936,7 @@ loanMakePayment(
|
||||
|
||||
periodic = detail::ExtendedPaymentComponents{
|
||||
detail::computePaymentComponents(
|
||||
view.rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
totalValueOutstandingProxy,
|
||||
@@ -1890,6 +1997,7 @@ loanMakePayment(
|
||||
// change
|
||||
auto periodicPaymentProxy = loan->at(sfPeriodicPayment);
|
||||
if (auto const overResult = detail::doOverpayment(
|
||||
view.rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
overpaymentComponents,
|
||||
|
||||
@@ -418,6 +418,7 @@ LoanSet::doApply()
|
||||
auto const paymentTotal = tx[~sfPaymentTotal].value_or(defaultPaymentTotal);
|
||||
|
||||
auto const properties = computeLoanProperties(
|
||||
view.rules(),
|
||||
vaultAsset,
|
||||
principalRequested,
|
||||
interestRate,
|
||||
|
||||
@@ -17,63 +17,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 +64,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 +77,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 +124,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 +138,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 +186,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 +195,251 @@ 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);
|
||||
}
|
||||
|
||||
void
|
||||
testComputeOverpaymentComponents()
|
||||
{
|
||||
@@ -606,6 +805,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 +815,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
loanScale);
|
||||
|
||||
auto const ret = tryOverpayment(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
overpaymentComponents,
|
||||
@@ -697,6 +898,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
managementFeeRate);
|
||||
|
||||
auto const loanProperties = computeLoanProperties(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanPrincipal,
|
||||
loanInterestRate,
|
||||
@@ -706,6 +908,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
loanScale);
|
||||
|
||||
auto const ret = tryOverpayment(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
overpaymentComponents,
|
||||
@@ -790,6 +993,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
managementFeeRate);
|
||||
|
||||
auto const loanProperties = computeLoanProperties(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanPrincipal,
|
||||
loanInterestRate,
|
||||
@@ -799,6 +1003,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
loanScale);
|
||||
|
||||
auto const ret = tryOverpayment(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
overpaymentComponents,
|
||||
@@ -889,6 +1094,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
managementFeeRate);
|
||||
|
||||
auto const loanProperties = computeLoanProperties(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanPrincipal,
|
||||
loanInterestRate,
|
||||
@@ -898,6 +1104,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
loanScale);
|
||||
|
||||
auto const ret = tryOverpayment(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
overpaymentComponents,
|
||||
@@ -996,6 +1203,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
managementFeeRate);
|
||||
|
||||
auto const loanProperties = computeLoanProperties(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanPrincipal,
|
||||
loanInterestRate,
|
||||
@@ -1005,6 +1213,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
loanScale);
|
||||
|
||||
auto const ret = tryOverpayment(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
overpaymentComponents,
|
||||
@@ -1103,6 +1312,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
managementFeeRate);
|
||||
|
||||
auto const loanProperties = computeLoanProperties(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanPrincipal,
|
||||
loanInterestRate,
|
||||
@@ -1112,6 +1322,7 @@ class LendingHelpers_test : public beast::unit_test::suite
|
||||
loanScale);
|
||||
|
||||
auto const ret = tryOverpayment(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
loanScale,
|
||||
overpaymentComponents,
|
||||
@@ -1199,8 +1410,11 @@ public:
|
||||
testLoanLatePaymentInterest();
|
||||
testLoanPeriodicPayment();
|
||||
testLoanPrincipalFromPeriodicPayment();
|
||||
testComputeRaisedRate();
|
||||
testLoanPrincipalFromPeriodicPaymentNearZeroRate();
|
||||
testComputePaymentFactor();
|
||||
testComputePowerMinusOne();
|
||||
testComputePowerMinusOneHybrid();
|
||||
testComputeTheoreticalLoanStateNearZeroRate();
|
||||
testComputeOverpaymentComponents();
|
||||
testComputeInterestAndFeeParts();
|
||||
}
|
||||
|
||||
@@ -715,6 +715,7 @@ protected:
|
||||
auto const total = loanParams.payTotal.value_or(LoanSet::defaultPaymentTotal);
|
||||
auto const feeRate = brokerParams.managementFeeRate;
|
||||
auto const props = computeLoanProperties(
|
||||
env.current()->rules(),
|
||||
asset,
|
||||
principal,
|
||||
interest,
|
||||
@@ -920,6 +921,7 @@ protected:
|
||||
state.totalValue, state.principalOutstanding, state.managementFeeOutstanding);
|
||||
{
|
||||
auto const raw = computeTheoreticalLoanState(
|
||||
env.current()->rules(),
|
||||
state.periodicPayment,
|
||||
periodicRate,
|
||||
state.paymentRemaining,
|
||||
@@ -963,6 +965,7 @@ protected:
|
||||
std::size_t totalPaymentsMade = 0;
|
||||
|
||||
xrpl::LoanState currentTrueState = computeTheoreticalLoanState(
|
||||
env.current()->rules(),
|
||||
state.periodicPayment,
|
||||
periodicRate,
|
||||
state.paymentRemaining,
|
||||
@@ -992,6 +995,7 @@ protected:
|
||||
validateBorrowerBalance();
|
||||
// Compute the expected principal amount
|
||||
auto const paymentComponents = xrpl::detail::computePaymentComponents(
|
||||
env.current()->rules(),
|
||||
broker.asset.raw(),
|
||||
state.loanScale,
|
||||
state.totalValue,
|
||||
@@ -1012,6 +1016,7 @@ protected:
|
||||
paymentComponents.trackedManagementFeeDelta);
|
||||
|
||||
xrpl::LoanState const nextTrueState = computeTheoreticalLoanState(
|
||||
env.current()->rules(),
|
||||
state.periodicPayment,
|
||||
periodicRate,
|
||||
state.paymentRemaining - 1,
|
||||
@@ -1409,6 +1414,7 @@ protected:
|
||||
auto state = getCurrentState(env, broker, keylet, verifyLoanStatus);
|
||||
|
||||
auto const loanProperties = computeLoanProperties(
|
||||
env.current()->rules(),
|
||||
broker.asset.raw(),
|
||||
state.principalOutstanding,
|
||||
state.interestRate,
|
||||
@@ -2537,6 +2543,7 @@ protected:
|
||||
|
||||
{
|
||||
auto const raw = computeTheoreticalLoanState(
|
||||
env.current()->rules(),
|
||||
state.periodicPayment,
|
||||
periodicRate,
|
||||
state.paymentRemaining,
|
||||
@@ -2574,6 +2581,7 @@ protected:
|
||||
std::size_t totalPaymentsMade = 0;
|
||||
|
||||
xrpl::LoanState currentTrueState = computeTheoreticalLoanState(
|
||||
env.current()->rules(),
|
||||
state.periodicPayment,
|
||||
periodicRate,
|
||||
state.paymentRemaining,
|
||||
@@ -2583,6 +2591,7 @@ protected:
|
||||
{
|
||||
// Compute the expected principal amount
|
||||
auto const paymentComponents = xrpl::detail::computePaymentComponents(
|
||||
env.current()->rules(),
|
||||
broker.asset.raw(),
|
||||
state.loanScale,
|
||||
state.totalValue,
|
||||
@@ -2600,6 +2609,7 @@ protected:
|
||||
", periodic payment: " + to_string(roundedPeriodicPayment));
|
||||
|
||||
xrpl::LoanState const nextTrueState = computeTheoreticalLoanState(
|
||||
env.current()->rules(),
|
||||
state.periodicPayment,
|
||||
periodicRate,
|
||||
state.paymentRemaining - 1,
|
||||
@@ -5500,7 +5510,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 =
|
||||
@@ -5729,6 +5743,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,
|
||||
@@ -5762,7 +5777,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,
|
||||
@@ -5795,7 +5813,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,
|
||||
@@ -7198,6 +7219,188 @@ protected:
|
||||
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
|
||||
}
|
||||
|
||||
// A near-zero interest rate (1 TenthBips = 0.0001%) 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
|
||||
@@ -7206,6 +7409,10 @@ public:
|
||||
testLoanPayLateFullPaymentBypassesPenalties();
|
||||
testLoanCoverMinimumRoundingExploit();
|
||||
#endif
|
||||
|
||||
testBugInterestDueDeltaCrash();
|
||||
testFullLifecycleVaultPnLNearZeroRate();
|
||||
|
||||
testWithdrawReflectsUnrealizedLoss();
|
||||
testInvalidLoanSet();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user