Compare commits

...

15 Commits

Author SHA1 Message Date
Vladislav Vysokikh
b1670f5614 clang-format fix 2026-06-05 11:54:14 +01:00
Vladislav Vysokikh
6056045f2e refactor: Use operator<=> for Number comparison 2026-06-05 11:40:52 +01:00
Ed Hennis
a489708326 Revert "Remove the fix: Use this commit to show that tests fail without the fix"
This reverts commit c42a478bb48a578aec670fc1c9ea9dc78208f935.
2026-06-04 19:53:33 -04:00
Ed Hennis
8b6b075397 Remove the fix: Use this commit to show that tests fail without the fix 2026-06-04 19:53:32 -04:00
Ed Hennis
cdcae49fdb Include vector
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-04 19:53:32 -04:00
Ed Hennis
763bba2aba Clean up the relationals Number test for readability
- Separate the int values from the non-int values.
2026-06-04 19:53:31 -04:00
Ed Hennis
35521e1065 Make the Relational tests way more comprehensive
- Test combinatorically with a large variety of values.
2026-06-04 19:53:30 -04:00
Ed Hennis
7f2d18f99e clang-format complains about the equality tests 2026-06-04 19:53:29 -04:00
Ed Hennis
4ca1c6d97f Improve readability 2026-06-04 19:53:28 -04:00
Ed Hennis
52c6652a56 Fix the problem 2026-06-04 19:53:27 -04:00
Ed Hennis
7ed000495c Add test cases to testRelationals to show the problem 2026-06-04 19:53:27 -04:00
Ed Hennis
89c38e6220 Revert "Comment out most Number comparisons"
This reverts commit a28e5777ba.
2026-06-04 19:53:26 -04:00
Ed Hennis
00d46c5423 Comment out most Number comparisons 2026-06-04 19:53:25 -04:00
Bart
7e15621e7b release: Bump version to 3.2.0-rc3 (#7371)
Co-authored-by: Bart <11445373+bthomee@users.noreply.github.com>
2026-05-31 22:55:18 +00:00
Vito Tumas
99431d7833 fix: Pin overpayment principal reduction to exact on-grid value (#7360) 2026-05-31 22:54:23 +00:00
7 changed files with 682 additions and 181 deletions

View File

@@ -3,6 +3,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <array>
#include <compare>
#include <cstdint>
#include <functional>
#include <limits>
@@ -401,40 +402,41 @@ public:
x.exponent_ == y.exponent_;
}
friend constexpr bool
operator!=(Number const& x, Number const& y) noexcept
// operator!=, >, <=, >= are synthesized from operator== and operator<=>.
friend constexpr std::strong_ordering
operator<=>(Number const& l, Number const& r) noexcept
{
return !(x == y);
}
bool const lneg = l.negative_;
bool const rneg = r.negative_;
friend constexpr bool
operator<(Number const& x, Number const& y) noexcept
{
// If the two amounts have different signs (zero is treated as positive)
// then the comparison is true iff the left is negative.
bool const lneg = x.negative_;
bool const rneg = y.negative_;
// then the negative one is smaller.
if (lneg != rneg)
return lneg;
{
return lneg ? std::strong_ordering::less : std::strong_ordering::greater;
}
// Both have same sign and the left is zero: the right must be
// greater than 0.
if (x.mantissa_ == 0)
return y.mantissa_ > 0;
// Same sign: compare the unsigned magnitudes |a| <=> |b|. For negative
// values the order is reversed (larger magnitude == smaller value), so
// swap the operands. A negative value is never zero, so the zero checks
// below only ever fire for the non-negative case.
Number const& a = lneg ? r : l;
Number const& b = lneg ? l : r;
// Both have same sign, the right is zero and the left is non-zero.
if (y.mantissa_ == 0)
return false;
// A zero mantissa carries a sentinel exponent, so zero has to be handled
// before the exponents can be compared.
if (a.mantissa_ == 0)
{
return b.mantissa_ == 0 ? std::strong_ordering::equal : std::strong_ordering::less;
}
if (b.mantissa_ == 0)
return std::strong_ordering::greater;
// Both have the same sign, compare by exponents:
if (x.exponent_ > y.exponent_)
return lneg;
if (x.exponent_ < y.exponent_)
return !lneg;
// If equal exponents, compare mantissas
return x.mantissa_ < y.mantissa_;
// Both are non-zero and normalized, so the exponent dominates and the
// mantissa breaks ties.
if (auto const cmp = a.exponent_ <=> b.exponent_; cmp != 0)
return cmp;
return a.mantissa_ <=> b.mantissa_;
}
/** Return the sign of the amount */
@@ -449,24 +451,6 @@ public:
[[nodiscard]] Number
truncate() const noexcept;
friend constexpr bool
operator>(Number const& x, Number const& y) noexcept
{
return y < x;
}
friend constexpr bool
operator<=(Number const& x, Number const& y) noexcept
{
return !(y < x);
}
friend constexpr bool
operator>=(Number const& x, Number const& y) noexcept
{
return !(x < y);
}
friend std::ostream&
operator<<(std::ostream& os, Number const& x)
{

View File

@@ -461,6 +461,7 @@ loanAccruedInterest(
ExtendedPaymentComponents
computeOverpaymentComponents(
Rules const& rules,
Asset const& asset,
int32_t const loanScale,
Number const& overpayment,

View File

@@ -559,15 +559,34 @@ tryOverpayment(
<< ", new total value: " << newLoanProperties.loanState.valueOutstanding
<< ", first payment principal: " << newLoanProperties.firstPaymentPrincipal;
// Calculate what the new loan state should be with the new periodic payment
// including rounding errors
auto const newTheoreticalState = computeTheoreticalLoanState(
rules,
newLoanProperties.periodicPayment,
periodicRate,
paymentRemaining,
managementFeeRate) +
errors;
// Calculate what the new loan state should be with the new periodic payment,
// including the preserved rounding errors.
auto const newTheoreticalState = [&]() {
auto const state = computeTheoreticalLoanState(
rules,
newLoanProperties.periodicPayment,
periodicRate,
paymentRemaining,
managementFeeRate) +
errors;
if (!rules.enabled(fixCleanup3_2_0))
return state;
// The new principal is known exactly: it is reduced by the overpayment's
// principal portion. computeTheoreticalLoanState instead derives the
// principal -- and, from it, the management fee and interest -- via a
// lossy (P * factor) / factor round-trip. Pin the principal to the exact
// value and re-derive the management fee from the exact interest gross
// (value - principal), so the intermediate state is fully consistent with
// the exact principal rather than the one-scale-unit-high round-trip.
Number const principal =
roundedOldState.principalOutstanding - overpaymentComponents.trackedPrincipalDelta;
Number const managementFee =
tenthBipsOfValue(state.valueOutstanding - principal, managementFeeRate);
return constructLoanState(state.valueOutstanding, principal, managementFee);
}();
JLOG(j.debug()) << "new theoretical value: " << newTheoreticalState.valueOutstanding
<< ", principal: " << newTheoreticalState.principalOutstanding
@@ -762,13 +781,6 @@ doOverpayment(
// The proxies still hold the original (pre-overpayment) values, which
// allows us to compute deltas and verify they match what we expect
// from the overpaymentComponents and loanPaymentParts.
XRPL_ASSERT_PARTS(
overpaymentComponents.trackedPrincipalDelta ==
principalOutstandingProxy - newRoundedLoanState.principalOutstanding,
"xrpl::detail::doOverpayment",
"principal change agrees");
JLOG(j.debug()) << "valueChange: " << loanPaymentParts.valueChange
<< ", totalValue before: " << *totalValueOutstandingProxy
<< ", totalValue after: " << newRoundedLoanState.valueOutstanding
@@ -780,35 +792,50 @@ doOverpayment(
<< overpaymentComponents.trackedPrincipalDelta -
(totalValueOutstandingProxy - newRoundedLoanState.valueOutstanding);
// The valueChange returned by tryOverpayment satisfies
// valueChange = (newInterestDue - oldInterestDue) + untrackedInterest.
// Using the loan-state identity v = p + i + m and the adjacent
// `principal change agrees` assertion (dp = oldP - newP), this
// rearranges into three independently-computable terms:
//
// 1. TVO change beyond what principal repayment alone explains:
// newTVO - (oldTVO - dp)
// 2. Management fee released by re-amortization (positive when
// mfee decreased; zero when managementFeeRate == 0):
// oldMfee - newMfee
// 3. The overpayment's penalty interest part (= untrackedInterest
// for the overpayment path; see computeOverpaymentComponents):
// trackedInterestPart()
[[maybe_unused]] Number const tvoChange = newRoundedLoanState.valueOutstanding -
(totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta);
[[maybe_unused]] Number const managementFeeReleased =
managementFeeOutstandingProxy - newRoundedLoanState.managementFeeDue;
[[maybe_unused]] Number const interestPart = overpaymentComponents.trackedInterestPart();
// The three assertions below are invariants that only hold once
// fixCleanup3_2_0 pins the new principal to the exact reduction
// (oldPrincipal - trackedPrincipalDelta). Before the amendment, the lossy
// (P * factor) / factor round-trip can leave the new principal one
// scale-unit high, so these equalities do not hold on the pre-amendment
// code path and must be gated to match the fix they verify.
if (rules.enabled(fixCleanup3_2_0))
{
// The valueChange returned by tryOverpayment satisfies
// valueChange = (newInterestDue - oldInterestDue) + untrackedInterest.
// Using the loan-state identity v = p + i + m and the adjacent
// `principal change agrees` assertion (dp = oldP - newP), this
// rearranges into three independently-computable terms:
//
// 1. TVO change beyond what principal repayment alone explains:
// newTVO - (oldTVO - dp)
// 2. Management fee released by re-amortization (positive when
// mfee decreased; zero when managementFeeRate == 0):
// oldMfee - newMfee
// 3. The overpayment's penalty interest part (= untrackedInterest
// for the overpayment path; see computeOverpaymentComponents):
// trackedInterestPart()
[[maybe_unused]] Number const tvoChange = newRoundedLoanState.valueOutstanding -
(totalValueOutstandingProxy - overpaymentComponents.trackedPrincipalDelta);
[[maybe_unused]] Number const managementFeeReleased =
managementFeeOutstandingProxy - newRoundedLoanState.managementFeeDue;
[[maybe_unused]] Number const interestPart = overpaymentComponents.trackedInterestPart();
XRPL_ASSERT_PARTS(
loanPaymentParts.valueChange == tvoChange + managementFeeReleased + interestPart,
"xrpl::detail::doOverpayment",
"interest paid agrees");
XRPL_ASSERT_PARTS(
overpaymentComponents.trackedPrincipalDelta ==
principalOutstandingProxy - newRoundedLoanState.principalOutstanding,
"xrpl::detail::doOverpayment",
"principal change agrees");
XRPL_ASSERT_PARTS(
overpaymentComponents.trackedPrincipalDelta == loanPaymentParts.principalPaid,
"xrpl::detail::doOverpayment",
"principal payment matches");
XRPL_ASSERT_PARTS(
loanPaymentParts.valueChange == tvoChange + managementFeeReleased + interestPart,
"xrpl::detail::doOverpayment",
"interest paid agrees");
XRPL_ASSERT_PARTS(
overpaymentComponents.trackedPrincipalDelta == loanPaymentParts.principalPaid,
"xrpl::detail::doOverpayment",
"principal payment matches");
}
// All validations passed, so update the proxy objects (which will
// modify the actual Loan ledger object)
@@ -1144,11 +1171,13 @@ computePaymentComponents(
// Cap each component to never exceed what's actually outstanding
deltas.principal = std::min(deltas.principal, currentLedgerState.principalOutstanding);
XRPL_ASSERT_PARTS(
deltas.interest <= currentLedgerState.interestDue,
"xrpl::detail::computePaymentComponents",
"interest due delta not greater than outstanding");
if (fixCleanup320Enabled)
{
XRPL_ASSERT_PARTS(
deltas.interest <= currentLedgerState.interestDue,
"xrpl::detail::computePaymentComponents",
"interest due delta not greater than outstanding");
}
// Cap interest to both the outstanding amount AND what's left of the
// periodic payment after principal is paid
deltas.interest = std::min(
@@ -1289,6 +1318,7 @@ computePaymentComponents(
*/
ExtendedPaymentComponents
computeOverpaymentComponents(
Rules const& rules,
Asset const& asset,
int32_t const loanScale,
Number const& overpayment,
@@ -1296,10 +1326,13 @@ computeOverpaymentComponents(
TenthBips32 const overpaymentFeeRate,
TenthBips16 const managementFeeRate)
{
XRPL_ASSERT(
overpayment > 0 && isRounded(asset, overpayment, loanScale),
"xrpl::detail::computeOverpaymentComponents : valid overpayment "
"amount");
if (rules.enabled(fixCleanup3_2_0))
{
XRPL_ASSERT(
overpayment > 0 && isRounded(asset, overpayment, loanScale),
"xrpl::detail::computeOverpaymentComponents : valid overpayment "
"amount");
}
// First, deduct the fixed overpayment fee from the total amount.
// This reduces the effective payment that will be applied to the loan.
@@ -2055,6 +2088,7 @@ loanMakePayment(
{
detail::ExtendedPaymentComponents const overpaymentComponents =
detail::computeOverpaymentComponents(
view.rules(),
asset,
loanScale,
overpayment,

View File

@@ -23,7 +23,7 @@ namespace {
//------------------------------------------------------------------------------
// clang-format off
// NOLINTNEXTLINE(readability-identifier-naming)
char const* const versionString = "3.2.0-rc2"
char const* const versionString = "3.2.0-rc3"
// clang-format on
;

View File

@@ -17,6 +17,7 @@
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>
@@ -513,7 +514,9 @@ class LendingHelpers_test : public beast::unit_test::Suite
auto const expectedOverpaymentManagementFee = Number{10}; // 10% of 100
auto const expectedPrincipalPortion = Number{400}; // 1,000 - 100 - 500
Env const env{*this};
auto const components = xrpl::detail::computeOverpaymentComponents(
env.current()->rules(),
iou,
loanScale,
overpayment,
@@ -854,7 +857,13 @@ class LendingHelpers_test : public beast::unit_test::Suite
Number const overpaymentAmount{50};
auto const overpaymentComponents = computeOverpaymentComponents(
asset, loanScale, overpaymentAmount, TenthBips32(0), TenthBips32(0), managementFeeRate);
env.current()->rules(),
asset,
loanScale,
overpaymentAmount,
TenthBips32(0),
TenthBips32(0),
managementFeeRate);
auto const loanProperties = computeLoanProperties(
env.current()->rules(),
@@ -942,6 +951,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
auto const overpaymentComponents = computeOverpaymentComponents(
env.current()->rules(),
asset,
loanScale,
Number{50, 0},
@@ -1037,6 +1047,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
auto const overpaymentComponents = computeOverpaymentComponents(
env.current()->rules(),
asset,
loanScale,
Number{50, 0},
@@ -1138,6 +1149,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
auto const overpaymentComponents = computeOverpaymentComponents(
env.current()->rules(),
asset,
loanScale,
Number{50, 0},
@@ -1247,6 +1259,7 @@ class LendingHelpers_test : public beast::unit_test::Suite
auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
auto const overpaymentComponents = computeOverpaymentComponents(
env.current()->rules(),
asset,
loanScale,
Number{50, 0},
@@ -1344,7 +1357,6 @@ class LendingHelpers_test : public beast::unit_test::Suite
using namespace jtx;
using namespace xrpl::detail;
Env const env{*this};
Account const issuer{"issuer"};
PrettyAsset const asset = issuer["USD"];
std::int32_t const loanScale = -5;
@@ -1355,7 +1367,9 @@ class LendingHelpers_test : public beast::unit_test::Suite
std::uint32_t const paymentsRemaining = 10;
auto const periodicRate = loanPeriodicRate(loanInterestRate, paymentInterval);
Env const env{*this};
auto const overpaymentComponents = computeOverpaymentComponents(
env.current()->rules(),
asset,
loanScale,
Number{50, 0},
@@ -1363,87 +1377,97 @@ class LendingHelpers_test : public beast::unit_test::Suite
TenthBips32(10'000), // 10% overpayment fee
managementFeeRate);
auto const loanProperties = computeLoanProperties(
env.current()->rules(),
asset,
loanPrincipal,
loanInterestRate,
paymentInterval,
paymentsRemaining,
managementFeeRate,
loanScale);
struct Outcome
{
LoanPaymentParts parts;
LoanState oldState;
LoanState newState;
};
auto const ret = tryOverpayment(
env.current()->rules(),
asset,
loanScale,
overpaymentComponents,
loanProperties.loanState,
loanProperties.periodicPayment,
periodicRate,
paymentsRemaining,
managementFeeRate,
env.journal);
// Run tryOverpayment under a given amendment set. At this (non-near-zero)
// rate computeLoanProperties is amendment-independent, so the loan state
// is identical across the amendment; only tryOverpayment's fixCleanup3_2_0
// behaviour (the exact-principal pin and the management-fee re-derivation
// from that principal) differs.
auto run = [&](FeatureBitset features) -> std::optional<Outcome> {
Env const env{*this, features};
auto const loanProperties = computeLoanProperties(
env.current()->rules(),
asset,
loanPrincipal,
loanInterestRate,
paymentInterval,
paymentsRemaining,
managementFeeRate,
loanScale);
auto const ret = tryOverpayment(
env.current()->rules(),
asset,
loanScale,
overpaymentComponents,
loanProperties.loanState,
loanProperties.periodicPayment,
periodicRate,
paymentsRemaining,
managementFeeRate,
env.journal);
if (!BEAST_EXPECT(ret))
return std::nullopt;
return Outcome{
.parts = ret->first,
.oldState = loanProperties.loanState,
.newState = ret->second.loanState};
};
BEAST_EXPECT(ret);
auto const fixedOpt = run(testableAmendments());
auto const legacyOpt = run(testableAmendments() - fixCleanup3_2_0);
if (!fixedOpt || !legacyOpt)
{
BEAST_EXPECT(fixedOpt.has_value());
BEAST_EXPECT(legacyOpt.has_value());
return;
}
Outcome const& fixed = *fixedOpt;
Outcome const& legacy = *legacyOpt;
auto const& [actualPaymentParts, newLoanProperties] = *ret;
auto const& newState = newLoanProperties.loanState;
// Components that the amendment does not change. The management fee is
// charged against the overpayment interest portion first, so interest
// paid stays 4.5 and fee paid 5.5; the principal repaid is 40 in both.
auto checkCommon = [&](Outcome const& o, char const* tag) {
BEAST_EXPECTS(
(o.parts.interestPaid == Number{45, -1}),
std::string(tag) + " interestPaid " + to_string(o.parts.interestPaid));
BEAST_EXPECTS(
(o.parts.feePaid == Number{55, -1}),
std::string(tag) + " feePaid " + to_string(o.parts.feePaid));
BEAST_EXPECTS(
o.parts.principalPaid == 40,
std::string(tag) + " principalPaid " + to_string(o.parts.principalPaid));
BEAST_EXPECT(
o.parts.principalPaid ==
o.oldState.principalOutstanding - o.newState.principalOutstanding);
// v = p + i + m identity: the non-interest part of valueChange equals
// the interest-due change.
BEAST_EXPECT(
o.parts.valueChange - o.parts.interestPaid ==
o.newState.interestDue - o.oldState.interestDue);
};
checkCommon(fixed, "fixed");
checkCommon(legacy, "legacy");
// =========== VALIDATE PAYMENT PARTS ===========
// Since there is loan management fee, the fee is charged against
// overpayment interest portion first, so interest paid remains 4.5
BEAST_EXPECTS(
(actualPaymentParts.interestPaid == Number{45, -1}),
" interestPaid mismatch: expected 4.5, got " +
to_string(actualPaymentParts.interestPaid));
// With overpayment interest portion, value change should equal the
// interest decrease plus overpayment interest portion
BEAST_EXPECTS(
(actualPaymentParts.valueChange ==
Number{-164737, -5} + actualPaymentParts.interestPaid),
" valueChange mismatch: expected " +
to_string(Number{-164737, -5} + actualPaymentParts.interestPaid) + ", got " +
to_string(actualPaymentParts.valueChange));
// While there is no overpayment fee, fee paid should equal the
// management fee charged against the overpayment interest portion
BEAST_EXPECTS(
(actualPaymentParts.feePaid == Number{55, -1}),
" feePaid mismatch: expected 5.5, got " + to_string(actualPaymentParts.feePaid));
BEAST_EXPECTS(
actualPaymentParts.principalPaid == 40,
" principalPaid mismatch: expected 40, got `" +
to_string(actualPaymentParts.principalPaid));
// =========== VALIDATE STATE CHANGES ===========
BEAST_EXPECTS(
actualPaymentParts.principalPaid ==
loanProperties.loanState.principalOutstanding - newState.principalOutstanding,
" principalPaid mismatch: expected " +
to_string(
loanProperties.loanState.principalOutstanding - newState.principalOutstanding) +
", got " + to_string(actualPaymentParts.principalPaid));
// Note that the management fee value change is not captured, as this
// value is not needed to correctly update the Vault state.
BEAST_EXPECTS(
(newState.managementFeeDue - loanProperties.loanState.managementFeeDue ==
Number{-18304, -5}),
" management fee change mismatch: expected " + to_string(Number{-18304, -5}) +
", got " +
to_string(newState.managementFeeDue - loanProperties.loanState.managementFeeDue));
BEAST_EXPECTS(
actualPaymentParts.valueChange - actualPaymentParts.interestPaid ==
newState.interestDue - loanProperties.loanState.interestDue,
" valueChange mismatch: expected " +
to_string(newState.interestDue - loanProperties.loanState.interestDue) + ", got " +
to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid));
// With fixCleanup3_2_0 the management fee is re-derived from the exact
// principal; without it, from the one-scale-unit-high round-trip
// principal. So the management fee outstanding (and hence the value
// change, via v = p + i + m) differ by exactly one scale-unit (1e-5 at
// loanScale -5) between the two paths.
BEAST_EXPECT((fixed.parts.valueChange == Number{-164738, -5} + fixed.parts.interestPaid));
BEAST_EXPECT(
(fixed.newState.managementFeeDue - fixed.oldState.managementFeeDue ==
Number{-18303, -5}));
BEAST_EXPECT((legacy.parts.valueChange == Number{-164737, -5} + legacy.parts.interestPaid));
BEAST_EXPECT(
(legacy.newState.managementFeeDue - legacy.oldState.managementFeeDue ==
Number{-18304, -5}));
}
public:

View File

@@ -7580,6 +7580,366 @@ protected:
attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS);
}
// A residual overpayment can reduce the stored principal by one scale-unit
// *less* than computeOverpaymentComponents predicts, firing the
// "principal change agrees" XRPL_ASSERT_PARTS in doOverpayment:
//
// trackedPrincipalDelta == principalOutstanding - newPrincipalOutstanding
//
// tryOverpayment re-amortizes the loan at the reduced principal, then
// re-derives the theoretical principal from the new periodic payment via
// (P * paymentFactor) / paymentFactor. That round-trip is not exact in
// Number's 19-digit arithmetic; a positive residual pushes the recomputed
// principal a hair above the exact grid point `oldPrincipal - delta`, and
// the Upward rounding in tryOverpayment then bumps it a full scale-unit
// higher. The principal therefore drops by `delta - 1 unit`, not `delta`.
//
// Concrete case (isolated, at the tryOverpayment level):
// A 100 USD loan at the minimum non-zero rate, 3 payments, loanScale -10.
// After one regular payment (principalOutstanding 66.6666666674) a residual overpayment of
// 0.049999998 yields trackedPrincipalDelta 0.048999998 but only reduces the principal by
// 0.0489999979 (newPrincipal 66.6176666695) — short by 1e-10.
//
// With fixCleanup3_2_0, tryOverpayment pins the new principal to the exact,
// on-grid reduction (oldPrincipal - trackedPrincipalDelta) instead of the
// lossy (P*factor)/factor round-trip, so the assertion holds and the
// overpayment applies cleanly. The three "principal change agrees" /
// "interest paid agrees" / "principal payment matches" assertions are
// gated behind the same amendment, so without it they are disabled (the
// server does not abort) and the loan keeps the pre-amendment computation.
//
// The test runs the same scenario under both amendment settings and checks
// the stored principal against a ground-truth value derived independently of
// the loan-state computation under test.
void
testBugOverpaymentPrincipalChange()
{
testcase("bug: doOverpayment asserts 'principal change agrees'");
using namespace jtx;
using namespace loan;
using namespace xrpl::detail;
struct Params
{
TenthBips32 interestRate;
TenthBips16 managementFeeRate;
std::uint32_t paymentTotal;
std::uint32_t paymentInterval;
std::int64_t principal;
Number overpayment;
TenthBips32 overpaymentInterestRate;
TenthBips32 overpaymentFeeRate;
std::optional<int> vaultScale;
};
struct Result
{
Number principalOutstanding; // stored principal after the LoanPay
Number expectedNewPrincipal; // ground truth, independent of the fix
Number managementFeeChange; // managementFeeOutstanding after - before
Number unit; // one scale-unit at the loan scale
};
auto runScenario = [this](FeatureBitset features, Params const& p) -> Result {
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"vaultOwner"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
Asset const asset = iouAsset.raw();
STAmount const iouLimit{asset, Number{9'999'999'999'999'999LL}};
env(trust(lender, iouLimit));
env(trust(borrower, iouLimit));
env(pay(issuer, lender, iouAsset(1'000'000)));
env(pay(issuer, borrower, iouAsset(1'000'000)));
env.close();
auto const broker = createVaultAndBroker(
env,
iouAsset,
lender,
{.vaultDeposit = 900'000,
.debtMax = 0,
.managementFeeRate = p.managementFeeRate,
.vaultScale = p.vaultScale});
auto const brokerSle = env.le(broker.brokerKeylet());
BEAST_EXPECT(brokerSle);
auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
env(set(borrower, broker.brokerID, Number{p.principal}, tfLoanOverpayment),
Sig(sfCounterpartySignature, lender),
kInterestRate(p.interestRate),
kPaymentTotal(p.paymentTotal),
kPaymentInterval(p.paymentInterval),
kGracePeriod(p.paymentInterval),
kOverpaymentFee(p.overpaymentFeeRate),
kOverpaymentInterestRate(p.overpaymentInterestRate),
Fee(env.current()->fees().base * 2),
Ter(tesSUCCESS));
env.close();
// The single LoanPay below makes one regular payment (the overpayment
// is smaller than one period) and leaves the residual as an
// overpayment.
auto const s = getCurrentState(env, broker, loanKeylet);
auto const periodicRate = loanPeriodicRate(s.interestRate, s.paymentInterval);
auto const onePeriod = computePaymentComponents(
env.current()->rules(),
asset,
s.loanScale,
s.totalValue,
s.principalOutstanding,
s.managementFeeOutstanding,
s.periodicPayment,
periodicRate,
s.paymentRemaining,
p.managementFeeRate);
// Ground truth: the stored principal must drop by exactly the regular
// payment's principal portion plus the overpayment's principal
// portion. computeOverpaymentComponents depends only on the
// overpayment amount and rates (not on the loan-state computation
// under test), so it is an independent oracle. Both components are
// computed under the same rules as the env so the payment factor
// matches.
auto const overpaymentComponents = computeOverpaymentComponents(
env.current()->rules(),
asset,
s.loanScale,
p.overpayment,
p.overpaymentInterestRate,
p.overpaymentFeeRate,
p.managementFeeRate);
Number const expectedNewPrincipal = s.principalOutstanding -
onePeriod.trackedPrincipalDelta - overpaymentComponents.trackedPrincipalDelta;
Number const managementFeeBefore = s.managementFeeOutstanding;
STAmount const payAmount{asset, onePeriod.trackedValueDelta + p.overpayment};
env(pay(borrower, loanKeylet.key, payAmount),
Txflags(tfLoanOverpayment),
Ter(tesSUCCESS));
env.close();
auto const loanSle = env.le(loanKeylet);
BEAST_EXPECT(loanSle);
return Result{
.principalOutstanding = loanSle ? Number{loanSle->at(sfPrincipalOutstanding)} : 0,
.expectedNewPrincipal = expectedNewPrincipal,
.managementFeeChange =
(loanSle ? Number{loanSle->at(sfManagementFeeOutstanding)} : Number{0}) -
managementFeeBefore,
.unit = Number{1, s.loanScale}};
};
// Scenario 1: the original near-zero-rate principal reproduction
// (loanScale -10, no management fee). 0.049999998 is smaller than one
// period, so it stays a residual overpayment.
Params const principalCase{
.interestRate = TenthBips32{1},
.managementFeeRate = TenthBips16{0},
.paymentTotal = 3,
.paymentInterval = 60,
.principal = 100,
.overpayment = Number{49999998, -9},
.overpaymentInterestRate = TenthBips32{1000},
.overpaymentFeeRate = TenthBips32{1000},
.vaultScale = 1};
// With fixCleanup3_2_0 the stored principal lands exactly on the
// ground-truth grid point: it is reduced by exactly the overpayment's
// principal portion. This is the key correctness check: if the principal
// pin were removed (even with the assertions still gated off), the lossy
// (P * factor) / factor round-trip would leave the principal one
// scale-unit high and this would fail.
Result const fixed = runScenario(all_, principalCase);
BEAST_EXPECTS(
fixed.principalOutstanding == fixed.expectedNewPrincipal,
"fixed principal " + to_string(fixed.principalOutstanding) + " != expected " +
to_string(fixed.expectedNewPrincipal));
// Without the amendment the loan amortizes with the catastrophically
// cancelling near-zero payment factor, so its schedule (and ground truth)
// differ from the fixed case; the gated assertions keep the server from
// aborting and the overpayment still lands exactly on that schedule.
Result const legacy = runScenario(all_ - fixCleanup3_2_0, principalCase);
BEAST_EXPECTS(
legacy.principalOutstanding == legacy.expectedNewPrincipal,
"legacy principal " + to_string(legacy.principalOutstanding) + " != expected " +
to_string(legacy.expectedNewPrincipal));
// Scenario 2: a normal-rate loan with a 10% management fee. At a normal
// rate the payment factor is identical across the amendment, so toggling
// fixCleanup3_2_0 isolates the fix. This overpayment (found by search)
// lands on a state where both the principal and the management fee differ
// by one scale-unit between the fixed and legacy paths.
Params const feeCase{
.interestRate = TenthBips32{10000},
.managementFeeRate = TenthBips16{10000},
.paymentTotal = 6,
.paymentInterval = 30u * 24 * 60 * 60,
.principal = 1000,
.overpayment = Number{214367363, -10},
.overpaymentInterestRate = TenthBips32{0},
.overpaymentFeeRate = TenthBips32{0},
.vaultScale = std::nullopt};
Result const feeFixed = runScenario(all_, feeCase);
Result const feeLegacy = runScenario(all_ - fixCleanup3_2_0, feeCase);
// With the fix the principal is the exact reduction; without it the lossy
// (P * factor) / factor round-trip leaves it one scale-unit high.
BEAST_EXPECTS(
feeFixed.principalOutstanding == feeFixed.expectedNewPrincipal,
"fee-case fixed principal " + to_string(feeFixed.principalOutstanding) +
" != expected " + to_string(feeFixed.expectedNewPrincipal));
BEAST_EXPECTS(
feeLegacy.principalOutstanding == feeLegacy.expectedNewPrincipal + feeLegacy.unit,
"fee-case legacy principal " + to_string(feeLegacy.principalOutstanding) +
" != expected " + to_string(feeLegacy.expectedNewPrincipal + feeLegacy.unit));
// Management fee: the overpayment re-amortizes a fee-bearing loan, so the management fee
// outstanding drops.
//
// Unlike the principal that is already at the correct precision, the re-amortized
// management fee is tenthBipsOfValue of the new schedule's gross interest, which depends
// on the recomputed periodic payment. So the expected change below is a pinned constant
// captured from a passing run a magic value only because there is nothing simpler to
// compare against.
//
// At the integration level, toggling the amendment also changes the regular payment's
// rounding so a fixed-vs-legacy comparison cannot isolate the overpayment management-fee
// fix.
BEAST_EXPECT(feeFixed.managementFeeChange == feeLegacy.managementFeeChange);
BEAST_EXPECTS(
(feeFixed.managementFeeChange == Number{-8219709543, -10}),
"fee-case mgmt fee change " + to_string(feeFixed.managementFeeChange));
}
// A LoanSet with InterestRate = 1 (0.001% annualized, the minimum non-zero
// rate). At such a near-zero rate the closed-form payment factor
// (1 + r)^n - 1 cancels catastrophically.
//
// Without fixCleanup3_2_0 the resulting amortization is degenerate and the
// LoanSet is rejected with tecPRECISION_LOSS (no loan created). With the
// amendment, computePowerMinusOneHybrid uses a numerically-stable series
// expansion, so the loan is created and the scheduled payments
// (2 * periodicPayment) cover the principal — no economic underpayment
// (yield theft).
//
// The test runs the same LoanSet under both amendment settings and pins the
// exact outcome for each.
void
testLoanSetNearZeroInterestRateSucceeds()
{
testcase("LoanSet near-zero interest rate covers principal");
using namespace jtx;
using namespace loan;
Number const principalRequested{1000};
struct Result
{
TER ter = tesSUCCESS;
bool created = false;
std::int32_t loanScale = 0;
Number principal;
Number totalValue;
Number managementFee;
Number periodicPayment;
};
auto runScenario = [&](FeatureBitset features, TER expectedTer) -> Result {
Env env(*this, features);
Account const issuer{"issuer"};
Account const lender{"vaultOwner"};
Account const borrower{"borrower"};
env.fund(XRP(1'000'000), issuer, lender, borrower);
env(fset(issuer, asfDefaultRipple));
env.close();
PrettyAsset const iouAsset = issuer["USD"];
STAmount const iouLimit{iouAsset.raw(), Number{9'999'999'999'999'999LL}};
env(trust(lender, iouLimit));
env(trust(borrower, iouLimit));
env(pay(issuer, lender, iouAsset(1'000'000)));
env(pay(issuer, borrower, iouAsset(1'000'000)));
env.close();
auto const broker = createVaultAndBroker(
env,
iouAsset,
lender,
{.vaultDeposit = 100'000, .debtMax = 0, .managementFeeRate = TenthBips16{0}});
auto const brokerSle = env.le(broker.brokerKeylet());
BEAST_EXPECT(brokerSle);
auto const loanSequence = brokerSle ? brokerSle->at(sfLoanSequence) : 0;
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
env(set(borrower, broker.brokerID, principalRequested),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{1}),
kPaymentTotal(2),
kPaymentInterval(400),
Fee(env.current()->fees().base * 2),
Ter(expectedTer));
env.close();
Result r;
r.ter = env.ter();
if (auto const loanSle = env.le(loanKeylet))
{
r.created = true;
r.loanScale = loanSle->at(sfLoanScale);
r.principal = loanSle->at(sfPrincipalOutstanding);
r.totalValue = loanSle->at(sfTotalValueOutstanding);
r.managementFee = loanSle->at(sfManagementFeeOutstanding);
r.periodicPayment = loanSle->at(sfPeriodicPayment);
}
return r;
};
Result const fixed = runScenario(all_, tesSUCCESS);
Result const legacy = runScenario(all_ - fixCleanup3_2_0, tecPRECISION_LOSS);
// Without the amendment, the catastrophically-cancelling closed-form
// payment factor produces a degenerate amortization that fails
// checkLoanGuards: the LoanSet is rejected with tecPRECISION_LOSS and no
// loan is created.
BEAST_EXPECT(legacy.ter == tecPRECISION_LOSS);
BEAST_EXPECT(!legacy.created);
// With the amendment the stable series expansion produces a valid loan
// at loanScale -10.
BEAST_EXPECT(fixed.ter == tesSUCCESS);
BEAST_EXPECT(fixed.created);
BEAST_EXPECT(fixed.loanScale == -10);
BEAST_EXPECT(fixed.principal == principalRequested);
BEAST_EXPECT((fixed.totalValue == Number{10000000001903, -10}));
BEAST_EXPECT(fixed.managementFee == beast::kZero);
// Periodic payment from the numerically-stable series expansion, and the
// scheduled total (2 * periodicPayment) which exceeds the 1000 principal
// — no economic underpayment / yield theft.
BEAST_EXPECT((fixed.periodicPayment == Number{5000000000951293762, -16}));
BEAST_EXPECT((fixed.periodicPayment * 2 == Number{1000000000190258752, -15}));
BEAST_EXPECT(fixed.periodicPayment * 2 > principalRequested);
}
// An overpayment whose residual amount has more precision than loanScale
// fires the isRounded(asset, overpayment, loanScale) assertion in
// computeOverpaymentComponents (and a downstream "interest paid agrees"
@@ -8358,12 +8718,14 @@ protected:
testLimitExceeded();
testLoanSetBlockedLoanPayAllowedWhenCanTransferCleared();
testLendingCanTradeClearedNoImpact();
testBugOverpaymentPrincipalChange();
testBugOverpayUnroundedAmount();
for (auto const flags : {0u, tfLoanOverpayment})
testYieldTheftRounding(flags);
testBugInterestDueDeltaCrash();
testFullLifecycleVaultPnLNearZeroRate();
testLoanSetNearZeroInterestRateSucceeds();
}
// Tests run under each entry in amendmentCombinations().

View File

@@ -10,6 +10,7 @@
#include <boost/multiprecision/cpp_dec_float.hpp>
#include <boost/multiprecision/number.hpp>
#include <algorithm>
#include <array>
#include <cctype>
#include <cstdint>
@@ -20,6 +21,8 @@
#include <stdexcept>
#include <string>
#include <tuple>
#include <utility>
#include <vector>
namespace xrpl {
@@ -1386,10 +1389,103 @@ public:
testRelationals()
{
testcase << "test_relationals " << to_string(Number::getMantissaScale());
BEAST_EXPECT(!(Number{100} < Number{10}));
BEAST_EXPECT(Number{100} > Number{10});
BEAST_EXPECT(Number{100} >= Number{10});
BEAST_EXPECT(!(Number{100} <= Number{10}));
{
auto test = [this](auto const& nums) {
BEAST_EXPECT(std::ranges::is_sorted(nums));
for (auto iter1 = nums.begin(); iter1 != nums.end(); ++iter1)
{
auto iter2 = iter1;
for (++iter2; iter2 != nums.end(); ++iter2)
{
Number const& smaller = *iter1;
Number const& larger = *iter2;
std::stringstream ss;
ss << smaller << " < " << larger;
auto const str = ss.str();
// The ==/!= operators use a completely different code path than <, etc.
// This helps detect a breakage in one but not the other. It also helps
// verify that the values are being ordered correctly.
BEAST_EXPECTS(smaller != larger, str + " (!=)");
BEAST_EXPECTS(!(smaller == larger), str + " (==)");
// true results using operator< and derived operators
BEAST_EXPECTS(smaller < larger, str + " (<)");
BEAST_EXPECTS(larger > smaller, str + " (>)");
BEAST_EXPECTS(larger >= smaller, str + " (>=)");
BEAST_EXPECTS(smaller <= larger, str + " (<=)");
// false results using operator< and derived operators
BEAST_EXPECTS(!(larger < smaller), str + " (! <)");
BEAST_EXPECTS(!(smaller > larger), str + " (! >)");
BEAST_EXPECTS(!(smaller >= larger), str + " (! >=)");
BEAST_EXPECTS(!(larger <= smaller), str + " (! <=)");
}
}
};
auto const intNums = [this]() {
// Inequality test cases are built from a list of sorted integers
auto const values =
std::to_array<int>({-100, -50, -20, -10, -1, 0, 1, 10, 20, 50, 100});
// Check this list is sorted before converting it to Numbers.
// That way if any of the other tests fail, we know it's because of code and not the
// source data.
BEAST_EXPECT(std::ranges::is_sorted(values));
std::vector<Number> result;
result.reserve(values.size());
for (auto const v : values)
result.emplace_back(v);
return result;
}();
auto const otherNums = std::to_array<Number>({
Number{-5, 100},
Number{-1, 100},
Number{-7, -10},
Number{-2, -10},
Number{0},
Number{2, -10},
Number{7, -10},
Number{1, 100},
Number{5, 100},
});
test(intNums);
test(otherNums);
}
{
// Equality test cases are <Number, __LINE__>. Number will be compared against itself
using Case = std::pair<Number, int>;
auto const c = std::to_array<Case>({
{700, __LINE__},
{50, __LINE__},
{1, __LINE__},
{0, __LINE__},
{-1, __LINE__},
{-30, __LINE__},
{-600, __LINE__},
});
for (auto const& [n, line] : c)
{
auto const str = to_string(n);
// NOLINTBEGIN(misc-redundant-expression) Explicitly testing operators with
// equivalent values
expect(n == n, str + " ==", __FILE__, line);
expect(!(n != n), str + " !=", __FILE__, line);
expect(!(n < n), str + " < ", __FILE__, line);
expect(!(n > n), str + " >", __FILE__, line);
expect(n >= n, str + " >=", __FILE__, line);
expect(n <= n, str + " <=", __FILE__, line);
// NOLINTEND(misc-redundant-expression)
}
}
}
void