Compare commits

..

1 Commits

Author SHA1 Message Date
Vito
26f82a2a16 fix: Numerically-stable (1+r)^n-1 in computePaymentFactor
At near-zero `periodicRate`, the direct subtraction
`power(1 + r, n) - 1` suffers catastrophic cancellation: `(1+r)^n`
rounds to a value very close to 1 and the subtraction discards most
of Number's 19-digit large-mantissa precision. The resulting
amortization factor is inaccurate enough that
`loanPrincipalFromPeriodicPayment` returns a principal greater than
`periodicPayment * paymentsRemaining`, which propagates into
`computeTheoreticalLoanState` as a negative `interestDue` and fires
the `interest due delta not greater than outstanding` assertion in
`computePaymentComponents` (testBugInterestDueDeltaCrash).

The same numerical defect causes systematic underpayment of
interest in release builds at the bug regime. On a $1B loan with
the test parameters, closed-form charges only $0.0588 of interest
versus the mathematically correct $0.3805 (verified independently
via 50-digit Decimal arithmetic). Linear scaling: ~$321 underpaid
per $1T of principal.

Replace `raisedRate - 1` with a hybrid evaluator:

- When `r * paymentsRemaining >= 1e-9`, use the closed-form
  `power(1+r, n) - 1`. At Number's 19-digit mantissa this still
  retains ~10 sig digits post-subtraction and is ~30-500x faster
  than the binomial expansion.
- Below the threshold, expand `(1+r)^n - 1 = sum C(n,k) r^k` with
  early termination once terms fall below Number precision.

The hybrid takes the closed-form path at every rate covered by
existing fixtures, so output is bit-identical to pre-fix code at
moderate rates (no fixture drift).

Also drops the now-unused `computeRaisedRate` from the lending
helpers (its only caller was `computePaymentFactor`).

Test coverage:

- testComputePowerMinusOne / testComputePowerMinusOneHybrid:
  direct unit tests for both new helpers, including property
  checks against `(1+r)^2 = 1 + 2r + r^2` and `(1+r)^3 = 1 + 3r +
  3r^2 + r^3`, threshold-boundary verification at exactly
  r*n = 1e-9, and large-n early-termination.
- testLoanPrincipalFromPeriodicPaymentNearZeroRate: regression
  guard, asserts `principal <= payment * n` at near-zero rate.
- testComputeTheoreticalLoanStateNearZeroRate: regression guard,
  asserts `interestDue >= 0` and `principalOutstanding <=
  valueOutstanding`.
- testBugInterestDueDeltaCrash: end-to-end reproduction of the
  original assertion abort, now passes cleanly.
- testFullLifecycleVaultPnLNearZeroRate: integration test running
  a $1B loan to completion, verifies the vault collects the
  economically-correct interest matching the 50-digit Decimal
  reference within sub-microcent tolerance, plus self-consistency
  (vault gain == TVO - principal at LoanSet) and conservation
  (borrower outflow == vault gain + broker gain).
2026-04-27 18:43:00 +02:00
13 changed files with 697 additions and 184 deletions

View File

@@ -51,21 +51,20 @@ def generate_strategy_matrix(all: bool, config: Config) -> list:
# Only generate a subset of configurations in PRs.
if not all:
# Debian:
# - Bookworm using GCC 13: Debug on linux/amd64, set the reference
# fee to 500 and enable code coverage (which will be done below).
# - Bookworm using GCC 15: Debug on linux/amd64, enable Address and
# UB sanitizers (which will be done below).
# - Bookworm using GCC 13: Release on linux/amd64, set the reference
# fee to 500.
# - Bookworm using GCC 15: Debug on linux/amd64, enable code
# coverage (which will be done below).
# - Bookworm using Clang 16: Debug on linux/amd64, enable voidstar.
# - Bookworm using Clang 17: Release on linux/amd64, set the
# reference fee to 1000.
# - Bookworm using Clang 20: Debug on linux/amd64, enable Address
# and UB sanitizers (which will be done below).
# - Bookworm using Clang 20: Debug on linux/amd64.
if os["distro_name"] == "debian":
skip = True
if os["distro_version"] == "bookworm":
if (
f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-13"
and build_type == "Debug"
and build_type == "Release"
and architecture["platform"] == "linux/amd64"
):
cmake_args = f"-DUNIT_TEST_REFERENCE_FEE=500 {cmake_args}"
@@ -194,11 +193,11 @@ def generate_strategy_matrix(all: bool, config: Config) -> list:
):
continue
# Enable code coverage for Debian Bookworm using GCC 13 in Debug on
# linux/amd64.
# Enable code coverage for Debian Bookworm using GCC 15 in Debug on
# linux/amd64
if (
f"{os['distro_name']}-{os['distro_version']}" == "debian-bookworm"
and f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-13"
and f"{os['compiler_name']}-{os['compiler_version']}" == "gcc-15"
and build_type == "Debug"
and architecture["platform"] == "linux/amd64"
):
@@ -235,39 +234,23 @@ def generate_strategy_matrix(all: bool, config: Config) -> list:
# Add the configuration to the list, with the most unique fields first,
# so that they are easier to identify in the GitHub Actions UI, as long
# names get truncated.
# Add Address and UB sanitizers as separate configurations for specific
# bookworm distros. Thread sanitizer is currently disabled (see below).
# Add Address and Thread (both coupled with UB) sanitizers for specific bookworm distros.
# GCC-Asan xrpld-embedded tests are failing because of https://github.com/google/sanitizers/issues/856
if os[
"distro_version"
] == "bookworm" and f"{os['compiler_name']}-{os['compiler_version']}" in [
"gcc-15",
"clang-20",
]:
# Add ASAN configuration.
if (
os["distro_version"] == "bookworm"
and f"{os['compiler_name']}-{os['compiler_version']}" == "clang-20"
):
# Add ASAN + UBSAN configuration.
configurations.append(
{
"config_name": config_name + "-asan",
"config_name": config_name + "-asan-ubsan",
"cmake_args": cmake_args,
"cmake_target": cmake_target,
"build_only": build_only,
"build_type": build_type,
"os": os,
"architecture": architecture,
"sanitizers": "address",
}
)
# Add UBSAN configuration.
configurations.append(
{
"config_name": config_name + "-ubsan",
"cmake_args": cmake_args,
"cmake_target": cmake_target,
"build_only": build_only,
"build_type": build_type,
"os": os,
"architecture": architecture,
"sanitizers": "undefinedbehavior",
"sanitizers": "address,undefinedbehavior",
}
)
# TSAN is deactivated due to seg faults with latest compilers.

View File

@@ -67,10 +67,8 @@ private:
}
else
{
for (; elapsed > 0; --elapsed)
{
while ((elapsed--) != 0u)
m_value -= (m_value + Window - 1) / Window;
}
}
}

View File

@@ -43,10 +43,8 @@ public:
: work_(boost::asio::make_work_guard(ios_))
{
threads_.reserve(concurrency);
for (std::size_t i = 0; i < concurrency; ++i)
{
while ((concurrency--) != 0u)
threads_.emplace_back([&] { ios_.run(); });
}
}
~enable_yield_to()

View File

@@ -363,10 +363,13 @@ 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
[[nodiscard]] Number
computePowerMinusOneHybrid(Number const& periodicRate, std::uint32_t paymentsRemaining);
[[nodiscard]] Number
computePaymentFactor(Number const& periodicRate, std::uint32_t paymentsRemaining);
std::pair<Number, Number>

View File

@@ -54,9 +54,8 @@ read_varint(void const* buf, std::size_t buflen, std::size_t& t)
return 1;
}
auto const used = n;
while (n > 0)
while (n--)
{
--n;
auto const d = p[n];
auto const t0 = t;
t *= 127;

View File

@@ -11,6 +11,7 @@ float-cast-overflow:external
float-divide-by-zero:external
function:external
implicit-integer-sign-change:external
implicit-signed-integer-truncation::external
implicit-signed-integer-truncation:external
implicit-unsigned-integer-truncation:external
integer-divide-by-zero:external
@@ -70,15 +71,145 @@ vla-bound:boost
vptr_check:boost
vptr:boost
# Google protobuf - intentional overflows in hash functions
# Google protobuf
undefined:protobuf
# Suppress UBSan errors in xrpld code by source file path
undefined:src/libxrpl/basics/base64.cpp
undefined:src/libxrpl/basics/Number.cpp
undefined:src/libxrpl/beast/utility/beast_Journal.cpp
undefined:src/libxrpl/crypto/RFC1751.cpp
undefined:src/libxrpl/ledger/ApplyView.cpp
undefined:src/libxrpl/ledger/View.cpp
undefined:src/libxrpl/protocol/Permissions.cpp
undefined:src/libxrpl/protocol/STAmount.cpp
undefined:src/libxrpl/protocol/STPathSet.cpp
undefined:src/libxrpl/protocol/tokens.cpp
undefined:src/libxrpl/shamap/SHAMap.cpp
undefined:src/test/app/Batch_test.cpp
undefined:src/test/app/Invariants_test.cpp
undefined:src/test/app/NFToken_test.cpp
undefined:src/test/app/Offer_test.cpp
undefined:src/test/app/Path_test.cpp
undefined:src/test/basics/XRPAmount_test.cpp
undefined:src/test/beast/LexicalCast_test.cpp
undefined:src/test/jtx/impl/acctdelete.cpp
undefined:src/test/ledger/SkipList_test.cpp
undefined:src/test/rpc/Subscribe_test.cpp
undefined:src/tests/libxrpl/basics/RangeSet.cpp
undefined:src/xrpld/app/main/BasicApp.cpp
undefined:src/xrpld/app/main/BasicApp.cpp
undefined:src/xrpld/app/misc/detail/AmendmentTable.cpp
undefined:src/xrpld/app/misc/NetworkOPs.cpp
undefined:src/libxrpl/json/json_value.cpp
undefined:src/xrpld/app/paths/detail/StrandFlow.h
undefined:src/xrpld/app/tx/detail/NFTokenMint.cpp
undefined:src/xrpld/app/tx/detail/OracleSet.cpp
undefined:src/xrpld/core/detail/JobQueue.cpp
undefined:src/xrpld/core/detail/Workers.cpp
undefined:src/xrpld/rpc/detail/Role.cpp
undefined:src/xrpld/rpc/handlers/GetAggregatePrice.cpp
undefined:xrpl/basics/base_uint.h
undefined:xrpl/basics/DecayingSample.h
undefined:xrpl/beast/test/yield_to.h
undefined:xrpl/beast/xor_shift_engine.h
undefined:xrpl/nodestore/detail/varint.h
undefined:xrpl/peerfinder/detail/Counts.h
undefined:xrpl/protocol/nft.h
# basic_string.h:483:51: runtime error: unsigned integer overflow
unsigned-integer-overflow:basic_string.h
unsigned-integer-overflow:bits/chrono.h
unsigned-integer-overflow:bits/random.h
unsigned-integer-overflow:bits/random.tcc
unsigned-integer-overflow:bits/stl_algobase.h
unsigned-integer-overflow:bits/uniform_int_dist.h
unsigned-integer-overflow:string_view
# runtime error: unsigned integer overflow: 0 - 1 cannot be represented in type 'std::size_t' (aka 'unsigned long')
unsigned-integer-overflow:src/libxrpl/basics/base64.cpp
unsigned-integer-overflow:src/libxrpl/basics/Number.cpp
unsigned-integer-overflow:src/libxrpl/crypto/RFC1751.cpp
unsigned-integer-overflow:rc/libxrpl/json/json_value.cpp
unsigned-integer-overflow:src/libxrpl/ledger/ApplyView.cpp
unsigned-integer-overflow:src/libxrpl/ledger/View.cpp
unsigned-integer-overflow:src/libxrpl/protocol/Permissions.cpp
unsigned-integer-overflow:src/libxrpl/protocol/STAmount.cpp
unsigned-integer-overflow:src/libxrpl/protocol/STPathSet.cpp
unsigned-integer-overflow:src/libxrpl/protocol/tokens.cpp
unsigned-integer-overflow:src/libxrpl/shamap/SHAMap.cpp
unsigned-integer-overflow:src/test/app/Batch_test.cpp
unsigned-integer-overflow:src/test/app/Invariants_test.cpp
unsigned-integer-overflow:src/test/app/NFToken_test.cpp
unsigned-integer-overflow:src/test/app/Offer_test.cpp
unsigned-integer-overflow:src/test/app/Path_test.cpp
unsigned-integer-overflow:src/test/basics/XRPAmount_test.cpp
unsigned-integer-overflow:src/test/beast/LexicalCast_test.cpp
unsigned-integer-overflow:src/test/jtx/impl/acctdelete.cpp
unsigned-integer-overflow:src/test/ledger/SkipList_test.cpp
unsigned-integer-overflow:src/test/rpc/Subscribe_test.cpp
unsigned-integer-overflow:src/tests/libxrpl/basics/RangeSet.cpp
unsigned-integer-overflow:src/xrpld/app/main/BasicApp.cpp
unsigned-integer-overflow:src/xrpld/app/misc/detail/AmendmentTable.cpp
unsigned-integer-overflow:src/xrpld/app/misc/NetworkOPs.cpp
unsigned-integer-overflow:src/xrpld/app/paths/detail/StrandFlow.h
unsigned-integer-overflow:src/xrpld/app/tx/detail/NFTokenMint.cpp
unsigned-integer-overflow:src/xrpld/app/tx/detail/OracleSet.cpp
unsigned-integer-overflow:src/xrpld/rpc/detail/Role.cpp
unsigned-integer-overflow:src/xrpld/rpc/handlers/GetAggregatePrice.cpp
unsigned-integer-overflow:xrpl/basics/base_uint.h
unsigned-integer-overflow:xrpl/basics/DecayingSample.h
unsigned-integer-overflow:xrpl/beast/test/yield_to.h
unsigned-integer-overflow:xrpl/beast/xor_shift_engine.h
unsigned-integer-overflow:xrpl/nodestore/detail/varint.h
unsigned-integer-overflow:xrpl/peerfinder/detail/Counts.h
unsigned-integer-overflow:xrpl/protocol/nft.h
# Xrpld intentional overflows and operations
# STAmount uses intentional negation of INT64_MIN and overflow in arithmetic
signed-integer-overflow:src/libxrpl/protocol/STAmount.cpp
unsigned-integer-overflow:src/libxrpl/protocol/STAmount.cpp
# XRPAmount test intentional overflows
signed-integer-overflow:src/test/basics/XRPAmount_test.cpp
# Peerfinder intentional overflow in counter arithmetic
unsigned-integer-overflow:src/xrpld/peerfinder/detail/Counts.h
# Signed integer overflow suppressions
signed-integer-overflow:src/test/beast/LexicalCast_test.cpp
# External library suppressions
unsigned-integer-overflow:nudb/detail/xxhash.hpp
# Loan_test.cpp intentional underflow in test arithmetic
unsigned-integer-overflow:src/test/app/Loan_test.cpp
undefined:src/test/app/Loan_test.cpp
# Source tree restructured paths (libxrpl/tx/transactors/)
# These duplicate the xrpld/app/tx/detail entries above for the new layout
unsigned-integer-overflow:src/libxrpl/tx/transactors/oracle/OracleSet.cpp
undefined:src/libxrpl/tx/transactors/oracle/OracleSet.cpp
unsigned-integer-overflow:src/libxrpl/tx/transactors/nft/NFTokenMint.cpp
undefined:src/libxrpl/tx/transactors/nft/NFTokenMint.cpp
# Protobuf intentional overflows in hash functions
# Protobuf uses intentional unsigned overflow for hash computation (stringpiece.h:393)
unsigned-integer-overflow:google/protobuf/stubs/stringpiece.h
# gRPC intentional overflows in timer calculations
# gRPC intentional overflows
# gRPC uses intentional overflow in timer calculations
unsigned-integer-overflow:grpc
unsigned-integer-overflow:timer_manager.cc
# RocksDB intentional unsigned integer overflows in hash functions and CRC calculations
# Standard library intentional overflows
# These are intentional overflows in random number generation and character conversion
unsigned-integer-overflow:__random/seed_seq.h
unsigned-integer-overflow:__charconv/traits.h
# Suppress errors in RocksDB
# RocksDB uses intentional unsigned integer overflows in hash functions and CRC calculations
unsigned-integer-overflow:rocks*/*/util/xxhash.h
unsigned-integer-overflow:rocks*/*/util/xxph3.h
unsigned-integer-overflow:rocks*/*/util/hash.cc
@@ -90,14 +221,13 @@ unsigned-integer-overflow:rocks*/*/table/format.cc
unsigned-integer-overflow:rocks*/*/table/block_based/block_based_table_builder.cc
unsigned-integer-overflow:rocks*/*/table/block_based/reader_common.cc
unsigned-integer-overflow:rocks*/*/db/version_set.cc
# RocksDB misaligned loads (intentional for performance on ARM64)
alignment:rocks*/*/util/crc32c_arm64.cc
undefined:rocks*/*/util/crc32c_arm64.cc
undefined:rocks*/*/util/xxhash.h
# nudb intentional overflows in hash functions
unsigned-integer-overflow:nudb/detail/xxhash.hpp
alignment:nudb/detail/xxhash.hpp
undefined:nudb
# Snappy compression library intentional overflows
unsigned-integer-overflow:snappy.cc
@@ -109,40 +239,10 @@ unsigned-integer-overflow:absl/base/internal/low_level_alloc.cc
unsigned-integer-overflow:absl/hash/internal/hash.h
unsigned-integer-overflow:absl/container/internal/raw_hash_set.h
# Standard library intentional overflows
unsigned-integer-overflow:basic_string.h
unsigned-integer-overflow:bits/chrono.h
unsigned-integer-overflow:bits/random.h
unsigned-integer-overflow:bits/random.tcc
unsigned-integer-overflow:bits/stl_algobase.h
unsigned-integer-overflow:bits/uniform_int_dist.h
unsigned-integer-overflow:string_view
unsigned-integer-overflow:__random/seed_seq.h
unsigned-integer-overflow:__charconv/traits.h
# Standard library intentional overflows in chrono duration arithmetic
unsigned-integer-overflow:__chrono/duration.h
# =============================================================================
# Rippled code suppressions
# =============================================================================
# Signed integer negation (-value) in amount types.
# INT64_MIN cannot occur in practice due to domain invariants (mantissa ranges
# are well within int64_t bounds), but UBSan flags the pattern as potential
# signed overflow. Narrowed to operator- to avoid suppressing unrelated
# overflows anywhere in a stack trace containing these type names.
signed-integer-overflow:operator-*IOUAmount*
signed-integer-overflow:operator-*XRPAmount*
signed-integer-overflow:operator-*MPTAmount*
signed-integer-overflow:operator-*STAmount*
# STAmount::operator+ signed addition — operands are bounded by total supply
# (~10^17 for XRP, ~10^18 for MPT) so overflow cannot occur in practice.
signed-integer-overflow:operator+*STAmount*
# STAmount::getRate uses unsigned shift and addition
unsigned-integer-overflow:*STAmount*getRate*
# STAmount::serialize uses unsigned bitwise operations
unsigned-integer-overflow:*STAmount*serialize*
# nft::cipheredTaxon uses intentional uint32 wraparound (LCG permutation)
unsigned-integer-overflow:cipheredTaxon
# Suppress undefined errors in RocksDB and nudb
undefined:rocks.*/*/util/crc32c_arm64.cc
undefined:rocks.*/*/util/xxhash.h
undefined:nudb

View File

@@ -107,7 +107,7 @@ encode(void* dest, void const* src, std::size_t len)
char const* in = static_cast<char const*>(src);
auto const tab = base64::get_alphabet();
for (auto n = len / 3; n > 0; --n)
for (auto n = len / 3; n != 0u; --n)
{
*out++ = tab[(in[0] & 0xfc) >> 2];
*out++ = tab[((in[0] & 0x03) << 4) + ((in[1] & 0xf0) >> 4)];

View File

@@ -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.
@@ -135,9 +211,10 @@ computePaymentFactor(Number const& periodicRate, std::uint32_t paymentsRemaining
if (periodicRate == beast::zero)
return Number{1} / paymentsRemaining;
Number const raisedRate = computeRaisedRate(periodicRate, paymentsRemaining);
Number const raisedRateMinusOne = computePowerMinusOneHybrid(periodicRate, paymentsRemaining);
Number const raisedRate = 1 + raisedRateMinusOne;
return (periodicRate * raisedRate) / (raisedRate - 1);
return (periodicRate * raisedRate) / raisedRateMinusOne;
}
/* Calculates the periodic payment amount using standard amortization formula.

View File

@@ -17,58 +17,6 @@ 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()
{
@@ -241,6 +189,246 @@ 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;
// 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(100, periodicRate, 3);
for (std::uint32_t n : {3u, 2u, 1u})
{
auto const computed =
loanPrincipalFromPeriodicPayment(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 xrpl::detail;
auto const periodicRate = loanPeriodicRate(TenthBips32{1}, 600);
auto const periodicPayment = loanPeriodicPayment(100, periodicRate, 3);
auto const state =
computeTheoreticalLoanState(periodicPayment, periodicRate, 2, TenthBips32{0});
BEAST_EXPECT(state.principalOutstanding <= state.valueOutstanding);
BEAST_EXPECT(state.interestDue >= 0);
BEAST_EXPECT(state.managementFeeDue == 0);
}
void
testComputeOverpaymentComponents()
{
@@ -1199,8 +1387,11 @@ public:
testLoanLatePaymentInterest();
testLoanPeriodicPayment();
testLoanPrincipalFromPeriodicPayment();
testComputeRaisedRate();
testLoanPrincipalFromPeriodicPaymentNearZeroRate();
testComputePaymentFactor();
testComputePowerMinusOne();
testComputePowerMinusOneHybrid();
testComputeTheoreticalLoanStateNearZeroRate();
testComputeOverpaymentComponents();
testComputeInterestAndFeeParts();
}

View File

@@ -7198,6 +7198,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 +7388,10 @@ public:
testLoanPayLateFullPaymentBypassesPenalties();
testLoanCoverMinimumRoundingExploit();
#endif
testBugInterestDueDeltaCrash();
testFullLifecycleVaultPnLNearZeroRate();
testWithdrawReflectsUnrealizedLoss();
testInvalidLoanSet();

View File

@@ -24,7 +24,7 @@ public:
testInteger(IntType in)
{
std::string s;
IntType out = static_cast<IntType>(~in); // Ensure out != in
IntType out(in + 1);
expect(lexicalCastChecked(s, in));
expect(lexicalCastChecked(out, s));

View File

@@ -12,10 +12,10 @@ BasicApp::BasicApp(std::size_t numberOfThreads)
work_.emplace(boost::asio::make_work_guard(io_context_));
threads_.reserve(numberOfThreads);
for (std::size_t i = 0; i < numberOfThreads; ++i)
while ((numberOfThreads--) != 0u)
{
threads_.emplace_back([this, i]() {
beast::setCurrentThreadName("io svc #" + std::to_string(i));
threads_.emplace_back([this, numberOfThreads]() {
beast::setCurrentThreadName("io svc #" + std::to_string(numberOfThreads));
this->io_context_.run();
});
}

View File

@@ -8,9 +8,6 @@
namespace xrpl::PeerFinder {
/** Direction of a slot count adjustment. */
enum class CountAdjustment : int { Decrement = -1, Increment = 1 };
/** Manages the count of available connections for the various slots. */
class Counts
{
@@ -19,14 +16,14 @@ public:
void
add(Slot const& s)
{
adjust(s, CountAdjustment::Increment);
adjust(s, 1);
}
/** Removes the slot state and properties from the slot counts. */
void
remove(Slot const& s)
{
adjust(s, CountAdjustment::Decrement);
adjust(s, -1);
}
/** Returns `true` if the slot can become active. */
@@ -210,40 +207,21 @@ public:
//--------------------------------------------------------------------------
private:
/** Increments or decrements a counter based on the adjustment direction. */
template <typename T>
static void
adjustCounter(T& counter, CountAdjustment dir)
{
switch (dir)
{
case CountAdjustment::Increment:
++counter;
break;
case CountAdjustment::Decrement:
--counter;
break;
}
}
// Adjusts counts based on the specified slot, in the direction indicated.
// Using ++/-- instead of += on std::size_t counters avoids UBSan
// unsigned-integer-overflow from implicit conversion of -1 to SIZE_MAX.
// A decrement on a zero counter is a real bug that UBSan should catch.
void
adjust(Slot const& s, CountAdjustment const dir)
adjust(Slot const& s, int const n)
{
if (s.fixed())
adjustCounter(m_fixed, dir);
m_fixed += n;
if (s.reserved())
adjustCounter(m_reserved, dir);
m_reserved += n;
switch (s.state())
{
case Slot::accept:
XRPL_ASSERT(s.inbound(), "xrpl::PeerFinder::Counts::adjust : input is inbound");
adjustCounter(m_acceptCount, dir);
m_acceptCount += n;
break;
case Slot::connect:
@@ -252,28 +230,28 @@ private:
!s.inbound(),
"xrpl::PeerFinder::Counts::adjust : input is not "
"inbound");
adjustCounter(m_attempts, dir);
m_attempts += n;
break;
case Slot::active:
if (s.fixed())
adjustCounter(m_fixed_active, dir);
m_fixed_active += n;
if (!s.fixed() && !s.reserved())
{
if (s.inbound())
{
adjustCounter(m_in_active, dir);
m_in_active += n;
}
else
{
adjustCounter(m_out_active, dir);
m_out_active += n;
}
}
adjustCounter(m_active, dir);
m_active += n;
break;
case Slot::closing:
adjustCounter(m_closingCount, dir);
m_closingCount += n;
break;
// LCOV_EXCL_START