diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 23b87de654..3268c48dc3 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -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 diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index caccca752d..cad52442d5 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -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. diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index 82c80f0158..8e5ed4538c 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -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{ - { - .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{ + {"r=5%, n=3", Number{5, -2}, 3}, + {"r=0.1%, n=1000", Number{1, -3}, 1'000}, + {"r=1e-7, n=100 (above threshold by 10x)", Number{1, -7}, 100}, + }; + for (auto const& tc : cases) + { + Number const closed = power(1 + tc.r, tc.n) - 1; + Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n); + BEAST_EXPECTS( + hybrid == closed, + tc.name + ": closed=" + to_string(closed) + ", hybrid=" + to_string(hybrid)); + } + } + + // Below threshold (r * n < 1e-9): hybrid must agree with + // `computePowerMinusOne` (the binomial expansion). At this regime + // the closed form is provably wrong (cancellation); we verify the + // dispatcher routes to the binomial path. + { + testcase( + "computePowerMinusOneHybrid: r*n < 1e-9 uses binomial expansion (bit-exact match)"); + + struct BelowThreshold + { + std::string name; + Number r; + std::uint32_t n; + }; + auto const cases = std::vector{ + // bug regime: r = 1 TenthBips32 over 600s payment interval + // → r ≈ 1.9e-10, r*n ≈ 3.8e-10 < 1e-9. + {"bug regime: r~1.9e-10, n=2", loanPeriodicRate(TenthBips32{1}, 600), 2}, + {"r=1e-12, n=100", Number{1, -12}, 100}, + }; + for (auto const& tc : cases) + { + Number const binom = computePowerMinusOne(tc.r, tc.n); + Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n); + BEAST_EXPECTS( + hybrid == binom, + tc.name + ": binom=" + to_string(binom) + ", hybrid=" + to_string(hybrid)); + } + } + + // Edge cases. + { + testcase("computePowerMinusOneHybrid: edge cases"); + Number const fivePercent{5, -2}; + BEAST_EXPECT(computePowerMinusOneHybrid(0, 100) == 0); + BEAST_EXPECT(computePowerMinusOneHybrid(fivePercent, 0) == 0); + BEAST_EXPECT(computePowerMinusOneHybrid(0, 0) == 0); + } + + // Threshold boundary: r*n = 1e-9 exactly. Hybrid uses `>=` against + // the threshold, so this case must take the closed-form branch. + // We also verify that the binomial path agrees with the closed + // form to high precision at this crossover — confirming the + // threshold is placed where both paths give "adequate" answers. + { + testcase("computePowerMinusOneHybrid: threshold boundary r*n = 1e-9"); + + // Construct exactly r*n = 1e-9 with two distinct (r, n) pairs. + struct Boundary + { + std::string name; + Number r; + std::uint32_t n; + }; + auto const cases = std::vector{ + {"r=1e-9, n=1", Number{1, -9}, 1}, + {"r=1e-12, n=1000", Number{1, -12}, 1'000}, + }; + + for (auto const& tc : cases) + { + Number const closed = power(1 + tc.r, tc.n) - 1; + Number const hybrid = computePowerMinusOneHybrid(tc.r, tc.n); + Number const binom = computePowerMinusOne(tc.r, tc.n); + + // At exact threshold, hybrid must take closed-form path: + // bit-exact match with closed. + BEAST_EXPECTS( + hybrid == closed, + tc.name + ": hybrid should equal closed at threshold; got hybrid=" + + to_string(hybrid) + ", closed=" + to_string(closed)); + + // Closed-form and binomial must agree at the threshold to + // within Number's post-subtraction precision (~10 sig + // digits of `r*n = 1e-9`, i.e. ~1e-19 absolute error). + Number const tolerance{1, -18}; + Number const diff = abs(closed - binom); + BEAST_EXPECTS( + diff < tolerance, + tc.name + ": closed and binomial diverge at threshold by " + to_string(diff)); + } + } + } + + // Regression: at near-zero rate, `loanPrincipalFromPeriodicPayment` + // must satisfy `principal <= periodicPayment * paymentsRemaining` for + // any non-negative rate. The naive closed-form path violated this + // bound due to catastrophic cancellation in `(1+r)^n - 1`. + void + testLoanPrincipalFromPeriodicPaymentNearZeroRate() + { + testcase("loanPrincipalFromPeriodicPayment: principal <= payment*n at near-zero rate"); + using namespace jtx; + using namespace xrpl::detail; + + // 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(); } diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 3fe727e368..8934425926 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -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()).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();