From 947ad002e0dd6e64d760b08cb209b644bbd99286 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Wed, 3 Dec 2025 18:35:25 -0500 Subject: [PATCH 1/5] Review feedback from @Tapanito: overpayment value change - In overpayment results, the management fee was being calculated twice: once as part of the value change, and as part of the fees paid. Exclude it from the value change. --- src/xrpld/app/misc/detail/LendingHelpers.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index 8020b47ba9..cb75444a90 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -553,8 +553,8 @@ tryOverpayment( // Calculate how the loan's value changed due to the overpayment. // This should be negative (value decreased) or zero. A principal // overpayment should never increase the loan's value. - auto const valueChange = - newRounded.valueOutstanding - hypotheticalValueOutstanding; + auto const valueChange = newRounded.valueOutstanding - + hypotheticalValueOutstanding - deltas.managementFee; if (valueChange > 0) { JLOG(j.warn()) << "Principal overpayment would increase the value of " From 0650e6e89dbd0e3371773983bc7fc1b888d70b65 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Wed, 3 Dec 2025 19:49:47 -0500 Subject: [PATCH 2/5] Fix LCOV exclusion --- src/xrpld/app/tx/detail/LoanBrokerDelete.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp index f3dd781bb5..ea98141ea6 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -67,7 +67,7 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "LoanBrokerDelete: Debt total is " << debtTotal << ", which rounds to " << rounded; return tecHAS_OBLIGATIONS; - // LCOV_EXCL_START + // LCOV_EXCL_STOP } } From 354531f946fa3b014c6e4cece20762d94e8d35fd Mon Sep 17 00:00:00 2001 From: Vito Tumas <5780819+Tapanito@users.noreply.github.com> Date: Thu, 4 Dec 2025 18:54:25 +0100 Subject: [PATCH 3/5] Fix Overpayment Calculation (#6087) - Adds additional unit tests to cover math calculations. - Removes unused methods. --- src/test/app/LendingHelpers_test.cpp | 642 +++++++++++++++++++ src/test/app/Loan_test.cpp | 9 +- src/xrpld/app/misc/LendingHelpers.h | 71 +- src/xrpld/app/misc/detail/LendingHelpers.cpp | 104 +-- 4 files changed, 718 insertions(+), 108 deletions(-) create mode 100644 src/test/app/LendingHelpers_test.cpp diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp new file mode 100644 index 0000000000..baed15b868 --- /dev/null +++ b/src/test/app/LendingHelpers_test.cpp @@ -0,0 +1,642 @@ +#include +// DO NOT REMOVE +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include +#include + +namespace ripple { +namespace test { + +class LendingHelpers_test : public beast::unit_test::suite +{ + void + testComputeRaisedRate() + { + using namespace jtx; + using namespace ripple::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() + { + using namespace jtx; + using namespace ripple::detail; + struct TestCase + { + std::string name; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPaymentFactor; + }; + + auto const testCases = std::vector{ + { + .name = "Zero periodic rate", + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPaymentFactor = Number{25, -2}, + }, // 1/4 = 0.25 + { + .name = "One payment remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 1, + .expectedPaymentFactor = Number{105, -2}, + }, // 0.05/1 = 1.05 + { + .name = "Multiple payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 3, + .expectedPaymentFactor = Number{367208564631245, -15}, + }, // from calc + { + .name = "Zero payments remaining", + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPaymentFactor = Number{0}, + } // edge case + }; + + for (auto const& tc : testCases) + { + testcase("computePaymentFactor: " + tc.name); + + auto const computedPaymentFactor = + computePaymentFactor(tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPaymentFactor == tc.expectedPaymentFactor, + "Payment factor mismatch: expected " + + to_string(tc.expectedPaymentFactor) + ", got " + + to_string(computedPaymentFactor)); + } + } + + void + testLoanPeriodicPayment() + { + using namespace jtx; + using namespace ripple::detail; + + struct TestCase + { + std::string name; + Number principalOutstanding; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPeriodicPayment; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .principalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 5, + .expectedPeriodicPayment = Number{0}, + }, + { + .name = "Zero payments remaining", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPeriodicPayment = Number{0}, + }, + { + .name = "Zero periodic rate", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPeriodicPayment = Number{250}, + }, + { + .name = "Standard case", + .principalOutstanding = Number{1'000}, + .periodicRate = + loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60), + .paymentsRemaining = 3, + .expectedPeriodicPayment = + Number{3895690663961231, -13}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanPeriodicPayment: " + tc.name); + + auto const computedPeriodicPayment = loanPeriodicPayment( + tc.principalOutstanding, tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPeriodicPayment == tc.expectedPeriodicPayment, + "Periodic payment mismatch: expected " + + to_string(tc.expectedPeriodicPayment) + ", got " + + to_string(computedPeriodicPayment)); + } + } + + void + testLoanPrincipalFromPeriodicPayment() + { + using namespace jtx; + using namespace ripple::detail; + + struct TestCase + { + std::string name; + Number periodicPayment; + Number periodicRate; + std::uint32_t paymentsRemaining; + Number expectedPrincipalOutstanding; + }; + + auto const testCases = std::vector{ + { + .name = "Zero periodic payment", + .periodicPayment = Number{0}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 5, + .expectedPrincipalOutstanding = Number{0}, + }, + { + .name = "Zero payments remaining", + .periodicPayment = Number{1'000}, + .periodicRate = Number{5, -2}, + .paymentsRemaining = 0, + .expectedPrincipalOutstanding = Number{0}, + }, + { + .name = "Zero periodic rate", + .periodicPayment = Number{250}, + .periodicRate = Number{0}, + .paymentsRemaining = 4, + .expectedPrincipalOutstanding = Number{1'000}, + }, + { + .name = "Standard case", + .periodicPayment = Number{3895690663961231, -13}, // from calc + .periodicRate = + loanPeriodicRate(TenthBips32(100'000), 30 * 24 * 60 * 60), + .paymentsRemaining = 3, + .expectedPrincipalOutstanding = Number{1'000}, + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanPrincipalFromPeriodicPayment: " + tc.name); + + auto const computedPrincipalOutstanding = + loanPrincipalFromPeriodicPayment( + tc.periodicPayment, tc.periodicRate, tc.paymentsRemaining); + BEAST_EXPECTS( + computedPrincipalOutstanding == tc.expectedPrincipalOutstanding, + "Principal outstanding mismatch: expected " + + to_string(tc.expectedPrincipalOutstanding) + ", got " + + to_string(computedPrincipalOutstanding)); + } + } + + void + testComputeOverpaymentComponents() + { + testcase("computeOverpaymentComponents"); + using namespace jtx; + using namespace ripple::detail; + + Account const issuer{"issuer"}; + PrettyAsset const IOU = issuer["IOU"]; + int32_t const loanScale = 1; + auto const overpayment = Number{1'000}; + auto const overpaymentInterestRate = TenthBips32{10'000}; // 10% + auto const overpaymentFeeRate = TenthBips32{50'000}; // 50% + auto const managementFeeRate = TenthBips16{10'000}; // 10% + + auto const expectedOverpaymentFee = Number{500}; // 50% of 1,000 + auto const expectedOverpaymentInterestGross = + Number{100}; // 10% of 1,000 + auto const expectedOverpaymentInterestNet = + Number{90}; // 100 - 10% of 100 + auto const expectedOverpaymentManagementFee = Number{10}; // 10% of 100 + auto const expectedPrincipalPortion = Number{400}; // 1,000 - 100 - 500 + + auto const components = detail::computeOverpaymentComponents( + IOU, + loanScale, + overpayment, + overpaymentInterestRate, + overpaymentFeeRate, + managementFeeRate); + + BEAST_EXPECT( + components.untrackedManagementFee == expectedOverpaymentFee); + + BEAST_EXPECT( + components.untrackedInterest == expectedOverpaymentInterestNet); + BEAST_EXPECT( + components.trackedManagementFeeDelta == + expectedOverpaymentManagementFee); + BEAST_EXPECT( + components.trackedPrincipalDelta == expectedPrincipalPortion); + BEAST_EXPECT( + components.trackedManagementFeeDelta + + components.untrackedInterest == + expectedOverpaymentInterestGross); + + BEAST_EXPECT( + components.trackedManagementFeeDelta + + components.untrackedInterest + + components.trackedPrincipalDelta + + components.untrackedManagementFee == + overpayment); + } + + void + testComputeInterestAndFeeParts() + { + using namespace jtx; + using namespace ripple::detail; + + struct TestCase + { + std::string name; + Number interest; + TenthBips16 managementFeeRate; + Number expectedInterestPart; + Number expectedFeePart; + }; + + Account const issuer{"issuer"}; + PrettyAsset const IOU = issuer["IOU"]; + std::int32_t const loanScale = 1; + + auto const testCases = std::vector{ + {.name = "Zero interest", + .interest = Number{0}, + .managementFeeRate = TenthBips16{10'000}, + .expectedInterestPart = Number{0}, + .expectedFeePart = Number{0}}, + {.name = "Zero fee rate", + .interest = Number{1'000}, + .managementFeeRate = TenthBips16{0}, + .expectedInterestPart = Number{1'000}, + .expectedFeePart = Number{0}}, + {.name = "10% fee rate", + .interest = Number{1'000}, + .managementFeeRate = TenthBips16{10'000}, + .expectedInterestPart = Number{900}, + .expectedFeePart = Number{100}}, + }; + + for (auto const& tc : testCases) + { + testcase("computeInterestAndFeeParts: " + tc.name); + + auto const [computedInterestPart, computedFeePart] = + computeInterestAndFeeParts( + IOU, tc.interest, tc.managementFeeRate, loanScale); + BEAST_EXPECTS( + computedInterestPart == tc.expectedInterestPart, + "Interest part mismatch: expected " + + to_string(tc.expectedInterestPart) + ", got " + + to_string(computedInterestPart)); + BEAST_EXPECTS( + computedFeePart == tc.expectedFeePart, + "Fee part mismatch: expected " + to_string(tc.expectedFeePart) + + ", got " + to_string(computedFeePart)); + } + } + + void + testLoanLatePaymentInterest() + { + using namespace jtx; + using namespace ripple::detail; + struct TestCase + { + std::string name; + Number principalOutstanding; + TenthBips32 lateInterestRate; + NetClock::time_point parentCloseTime; + std::uint32_t nextPaymentDueDate; + Number expectedLateInterest; + }; + + auto const testCases = std::vector{ + { + .name = "On-time payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 3'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "Early payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 4'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "No principal outstanding", + .principalOutstanding = Number{0}, + .lateInterestRate = TenthBips32{10'000}, // 10% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "No late interest rate", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{0}, // 0% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = Number{0}, + }, + { + .name = "Late payment", + .principalOutstanding = Number{1'000}, + .lateInterestRate = TenthBips32{100'000}, // 100% + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .nextPaymentDueDate = 2'000, + .expectedLateInterest = + Number{3170979198376459, -17}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanLatePaymentInterest: " + tc.name); + + auto const computedLateInterest = loanLatePaymentInterest( + tc.principalOutstanding, + tc.lateInterestRate, + tc.parentCloseTime, + tc.nextPaymentDueDate); + BEAST_EXPECTS( + computedLateInterest == tc.expectedLateInterest, + "Late interest mismatch: expected " + + to_string(tc.expectedLateInterest) + ", got " + + to_string(computedLateInterest)); + } + } + + void + testLoanAccruedInterest() + { + using namespace jtx; + using namespace ripple::detail; + struct TestCase + { + std::string name; + Number principalOutstanding; + Number periodicRate; + NetClock::time_point parentCloseTime; + std::uint32_t startDate; + std::uint32_t prevPaymentDate; + std::uint32_t paymentInterval; + Number expectedAccruedInterest; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .principalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Before start date", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{1'000}}, + .startDate = 2'000, + .prevPaymentDate = 1'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Zero periodic rate", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{0}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Zero payment interval", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 2'000, + .prevPaymentDate = 2'500, + .paymentInterval = 0, + .expectedAccruedInterest = Number{0}, + }, + { + .name = "Standard case", + .principalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .startDate = 1'000, + .prevPaymentDate = 2'000, + .paymentInterval = 30 * 24 * 60 * 60, + .expectedAccruedInterest = + Number{1929012345679012, -17}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("loanAccruedInterest: " + tc.name); + + auto const computedAccruedInterest = loanAccruedInterest( + tc.principalOutstanding, + tc.periodicRate, + tc.parentCloseTime, + tc.startDate, + tc.prevPaymentDate, + tc.paymentInterval); + BEAST_EXPECTS( + computedAccruedInterest == tc.expectedAccruedInterest, + "Accrued interest mismatch: expected " + + to_string(tc.expectedAccruedInterest) + ", got " + + to_string(computedAccruedInterest)); + } + } + + // This test overlaps with testLoanAccruedInterest, the test cases only + // exercise the computeFullPaymentInterest parts unique to it. + void + testComputeFullPaymentInterest() + { + using namespace jtx; + using namespace ripple::detail; + + struct TestCase + { + std::string name; + Number rawPrincipalOutstanding; + Number periodicRate; + NetClock::time_point parentCloseTime; + std::uint32_t paymentInterval; + std::uint32_t prevPaymentDate; + std::uint32_t startDate; + TenthBips32 closeInterestRate; + Number expectedFullPaymentInterest; + }; + + auto const testCases = std::vector{ + { + .name = "Zero principal outstanding", + .rawPrincipalOutstanding = Number{0}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{10'000}, + .expectedFullPaymentInterest = Number{0}, + }, + { + .name = "Zero close interest rate", + .rawPrincipalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{0}, + .expectedFullPaymentInterest = + Number{1929012345679012, -17}, // from calc + }, + { + .name = "Standard case", + .rawPrincipalOutstanding = Number{1'000}, + .periodicRate = Number{5, -2}, + .parentCloseTime = + NetClock::time_point{NetClock::duration{3'000}}, + .paymentInterval = 30 * 24 * 60 * 60, + .prevPaymentDate = 2'000, + .startDate = 1'000, + .closeInterestRate = TenthBips32{10'000}, + .expectedFullPaymentInterest = + Number{1000192901234568, -13}, // from calc + }, + }; + + for (auto const& tc : testCases) + { + testcase("computeFullPaymentInterest: " + tc.name); + + auto const computedFullPaymentInterest = computeFullPaymentInterest( + tc.rawPrincipalOutstanding, + tc.periodicRate, + tc.parentCloseTime, + tc.paymentInterval, + tc.prevPaymentDate, + tc.startDate, + tc.closeInterestRate); + BEAST_EXPECTS( + computedFullPaymentInterest == tc.expectedFullPaymentInterest, + "Full payment interest mismatch: expected " + + to_string(tc.expectedFullPaymentInterest) + ", got " + + to_string(computedFullPaymentInterest)); + } + } + +public: + void + run() override + { + testComputeFullPaymentInterest(); + testLoanAccruedInterest(); + testLoanLatePaymentInterest(); + testLoanPeriodicPayment(); + testLoanPrincipalFromPeriodicPayment(); + testComputeRaisedRate(); + testComputePaymentFactor(); + testComputeOverpaymentComponents(); + testComputeInterestAndFeeParts(); + } +}; + +BEAST_DEFINE_TESTSUITE(LendingHelpers, app, ripple); + +} // namespace test +} // namespace ripple diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index aa196d0e65..e4a9b71854 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -6145,15 +6145,16 @@ protected: // Accrued + prepayment-penalty interest based on current periodic // schedule auto const fullPaymentInterest = computeFullPaymentInterest( - after.periodicPayment, + detail::loanPrincipalFromPeriodicPayment( + after.periodicPayment, periodicRate2, after.paymentRemaining), periodicRate2, - after.paymentRemaining, env.current()->parentCloseTime(), after.paymentInterval, after.previousPaymentDate, static_cast( after.startDate.time_since_epoch().count()), closeInterestRate); + // Round to asset scale and split interest/fee parts auto const roundedInterest = roundToAsset(asset.raw(), fullPaymentInterest, after.loanScale); @@ -6181,9 +6182,9 @@ protected: // window by clamping prevPaymentDate to 'now' for the full-pay path. auto const prevClamped = std::min(after.previousPaymentDate, nowSecs); auto const fullPaymentInterestClamped = computeFullPaymentInterest( - after.periodicPayment, + detail::loanPrincipalFromPeriodicPayment( + after.periodicPayment, periodicRate2, after.paymentRemaining), periodicRate2, - after.paymentRemaining, env.current()->parentCloseTime(), after.paymentInterval, prevClamped, diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index 559af28a47..342cb3e58f 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -202,14 +202,6 @@ computeRawLoanState( std::uint32_t const paymentRemaining, TenthBips32 const managementFeeRate); -LoanState -computeRawLoanState( - Number const& periodicPayment, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t const paymentRemaining, - TenthBips32 const managementFeeRate); - // Constructs a valid LoanState object from arbitrary inputs LoanState constructLoanState( @@ -239,17 +231,6 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); -Number -computeFullPaymentInterest( - Number const& periodicPayment, - Number const& periodicRate, - std::uint32_t paymentRemaining, - NetClock::time_point parentCloseTime, - std::uint32_t paymentInterval, - std::uint32_t prevPaymentDate, - std::uint32_t startDate, - TenthBips32 closeInterestRate); - namespace detail { // These classes and functions should only be accessed by LendingHelper // functions and unit tests @@ -387,6 +368,58 @@ struct LoanStateDeltas nonNegative(); }; +Number +computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining); + +Number +computePaymentFactor( + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +std::pair +computeInterestAndFeeParts( + Asset const& asset, + Number const& interest, + TenthBips16 managementFeeRate, + std::int32_t loanScale); + +Number +loanPeriodicPayment( + Number const& principalOutstanding, + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +Number +loanPrincipalFromPeriodicPayment( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentsRemaining); + +Number +loanLatePaymentInterest( + Number const& principalOutstanding, + TenthBips32 lateInterestRate, + NetClock::time_point parentCloseTime, + std::uint32_t nextPaymentDueDate); + +Number +loanAccruedInterest( + Number const& principalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t startDate, + std::uint32_t prevPaymentDate, + std::uint32_t paymentInterval); + +ExtendedPaymentComponents +computeOverpaymentComponents( + Asset const& asset, + int32_t const loanScale, + Number const& overpayment, + TenthBips32 const overpaymentInterestRate, + TenthBips32 const overpaymentFeeRate, + TenthBips16 const managementFeeRate); + PaymentComponents computePaymentComponents( Asset const& asset, diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index 1361b0679a..393646c784 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -100,6 +100,9 @@ computePaymentFactor( Number const& periodicRate, std::uint32_t paymentsRemaining) { + if (paymentsRemaining == 0) + return numZero; + // For zero interest, payment factor is simply 1/paymentsRemaining if (periodicRate == beast::zero) return Number{1} / paymentsRemaining; @@ -132,27 +135,6 @@ loanPeriodicPayment( computePaymentFactor(periodicRate, paymentsRemaining); } -/* Calculates the periodic payment amount from annualized interest rate. - * Converts the annual rate to periodic rate before computing payment. - * - * Equation (7) from XLS-66 spec, Section A-2 Equation Glossary - */ -Number -loanPeriodicPayment( - Number const& principalOutstanding, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t paymentsRemaining) -{ - if (principalOutstanding == 0 || paymentsRemaining == 0) - return 0; - - Number const periodicRate = loanPeriodicRate(interestRate, paymentInterval); - - return loanPeriodicPayment( - principalOutstanding, periodicRate, paymentsRemaining); -} - /* Reverse-calculates principal from periodic payment amount. * Used to determine theoretical principal at any point in the schedule. * @@ -164,6 +146,9 @@ loanPrincipalFromPeriodicPayment( Number const& periodicRate, std::uint32_t paymentsRemaining) { + if (paymentsRemaining == 0) + return numZero; + if (periodicRate == 0) return periodicPayment * paymentsRemaining; @@ -171,21 +156,6 @@ loanPrincipalFromPeriodicPayment( computePaymentFactor(periodicRate, paymentsRemaining); } -/* Splits gross interest into net interest (to vault) and management fee (to - * broker). Returns pair of (net interest, management fee). - * - * Equation (33) from XLS-66 spec, Section A-2 Equation Glossary - */ -std::pair -computeInterestAndFeeParts( - Number const& interest, - TenthBips16 managementFeeRate) -{ - auto const fee = tenthBipsOfValue(interest, managementFeeRate); - - return std::make_pair(interest - fee, fee); -} - /* * Computes the interest and management fee parts from interest amount. * @@ -216,6 +186,12 @@ loanLatePaymentInterest( NetClock::time_point parentCloseTime, std::uint32_t nextPaymentDueDate) { + if (principalOutstanding == beast::zero) + return numZero; + + if (lateInterestRate == TenthBips32{0}) + return numZero; + auto const now = parentCloseTime.time_since_epoch().count(); // If the payment is not late by any amount of time, then there's no late @@ -248,6 +224,9 @@ loanAccruedInterest( if (periodicRate == beast::zero) return numZero; + if (paymentInterval == 0) + return numZero; + auto const lastPaymentDate = std::max(prevPaymentDate, startDate); auto const now = parentCloseTime.time_since_epoch().count(); @@ -1225,17 +1204,12 @@ computeOverpaymentComponents( // This interest doesn't follow the normal amortization schedule - it's // a one-time charge for paying early. // Equation (20) and (21) from XLS-66 spec, Section A-2 Equation Glossary - auto const [rawOverpaymentInterest, _] = [&]() { - Number const interest = - tenthBipsOfValue(overpayment, overpaymentInterestRate); - return detail::computeInterestAndFeeParts(interest, managementFeeRate); - }(); - - // Round the penalty interest components to the loan scale auto const [roundedOverpaymentInterest, roundedOverpaymentManagementFee] = [&]() { - Number const interest = - roundToAsset(asset, rawOverpaymentInterest, loanScale); + auto const interest = roundToAsset( + asset, + tenthBipsOfValue(overpayment, overpaymentInterestRate), + loanScale); return detail::computeInterestAndFeeParts( asset, interest, managementFeeRate, loanScale); }(); @@ -1429,31 +1403,6 @@ computeFullPaymentInterest( return accruedInterest + prepaymentPenalty; } -Number -computeFullPaymentInterest( - Number const& periodicPayment, - Number const& periodicRate, - std::uint32_t paymentRemaining, - NetClock::time_point parentCloseTime, - std::uint32_t paymentInterval, - std::uint32_t prevPaymentDate, - std::uint32_t startDate, - TenthBips32 closeInterestRate) -{ - Number const rawPrincipalOutstanding = - detail::loanPrincipalFromPeriodicPayment( - periodicPayment, periodicRate, paymentRemaining); - - return computeFullPaymentInterest( - rawPrincipalOutstanding, - periodicRate, - parentCloseTime, - paymentInterval, - prevPaymentDate, - startDate, - closeInterestRate); -} - /* Calculates the theoretical loan state at maximum precision for a given point * in the amortization schedule. * @@ -1515,21 +1464,6 @@ computeRawLoanState( .managementFeeDue = rawManagementFeeOutstanding}; }; -LoanState -computeRawLoanState( - Number const& periodicPayment, - TenthBips32 interestRate, - std::uint32_t paymentInterval, - std::uint32_t const paymentRemaining, - TenthBips32 const managementFeeRate) -{ - return computeRawLoanState( - periodicPayment, - loanPeriodicRate(interestRate, paymentInterval), - paymentRemaining, - managementFeeRate); -} - /* Constructs a LoanState from rounded Loan ledger object values. * * This function creates a LoanState structure from the three tracked values From b02b700532a74cc34db486144fd937e215286f06 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Fri, 5 Dec 2025 19:29:54 -0500 Subject: [PATCH 4/5] Update src/xrpld/app/tx/detail/LoanManage.cpp Co-authored-by: Shawn Xie <35279399+shawnxie999@users.noreply.github.com> --- src/xrpld/app/tx/detail/LoanManage.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index adf08d71bf..da86d7b56b 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -106,7 +106,7 @@ LoanManage::preclaim(PreclaimContext const& ctx) if (loanBrokerSle->at(sfOwner) != account) { JLOG(ctx.j.warn()) - << "LoanBroker for Loan does not belong to the account. LoanModify " + << "LoanBroker for Loan does not belong to the account. LoanManage " "can only be submitted by the Loan Broker."; return tecNO_PERMISSION; } From af43572ee5a408d2cd92e09685eca59efc4a624d Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Fri, 5 Dec 2025 21:04:53 -0500 Subject: [PATCH 5/5] Review feedback from @shawnxie999: even more rounding - Round the initial total value computation upward, unless there is 0-interest. - Rename getVaultScale to getAssetsTotalScale, and convert one incorrect computation to use it. - Use adjustImpreciseNumber for LossUnrealized. - Add some logging to computeLoanProperties. --- src/test/app/Loan_test.cpp | 48 ++++++++++++-------- src/xrpld/app/misc/LendingHelpers.h | 8 ++-- src/xrpld/app/misc/detail/LendingHelpers.cpp | 22 +++++++-- src/xrpld/app/tx/detail/LoanBrokerDelete.cpp | 2 +- src/xrpld/app/tx/detail/LoanDelete.cpp | 2 +- src/xrpld/app/tx/detail/LoanManage.cpp | 38 ++++++++++++---- src/xrpld/app/tx/detail/LoanManage.h | 2 + src/xrpld/app/tx/detail/LoanPay.cpp | 4 +- src/xrpld/app/tx/detail/LoanSet.cpp | 10 ++-- 9 files changed, 94 insertions(+), 42 deletions(-) diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index b2ad47c2b4..769492dd59 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -141,7 +141,7 @@ protected: using namespace jtx; auto const vaultSle = env.le(keylet::vault(vaultID)); - return getVaultScale(vaultSle); + return getAssetsTotalScale(vaultSle); } }; @@ -551,12 +551,15 @@ protected: broker.vaultScale(env), state.principalOutstanding.exponent()))); BEAST_EXPECT(state.paymentInterval == 600); - BEAST_EXPECT( - state.totalValue == - roundToAsset( - broker.asset, - state.periodicPayment * state.paymentRemaining, - state.loanScale)); + { + NumberRoundModeGuard mg(Number::upward); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, + state.periodicPayment * state.paymentRemaining, + state.loanScale)); + } BEAST_EXPECT( state.managementFeeOutstanding == computeManagementFee( @@ -697,7 +700,8 @@ protected: interval, total, feeRate, - asset(brokerParams.vaultDeposit).number().exponent()); + asset(brokerParams.vaultDeposit).number().exponent(), + env.journal); log << "Loan properties:\n" << "\tPrincipal: " << principal << std::endl << "\tInterest rate: " << interest << std::endl @@ -1477,7 +1481,8 @@ protected: state.paymentInterval, state.paymentRemaining, broker.params.managementFeeRate, - state.loanScale); + state.loanScale, + env.journal); verifyLoanStatus( 0, @@ -2448,13 +2453,18 @@ protected: // Make all the payments in one transaction // service fee is 2 auto const startingPayments = state.paymentRemaining; - auto const rawPayoff = startingPayments * - (state.periodicPayment + broker.asset(2).value()); - STAmount const payoffAmount{broker.asset, rawPayoff}; - BEAST_EXPECT( - payoffAmount == - broker.asset(Number(1024014840139457, -12))); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); + STAmount const payoffAmount = [&]() { + NumberRoundModeGuard mg(Number::upward); + auto const rawPayoff = startingPayments * + (state.periodicPayment + broker.asset(2).value()); + STAmount const payoffAmount{broker.asset, rawPayoff}; + BEAST_EXPECTS( + payoffAmount == + broker.asset(Number(1024014840139457, -12)), + to_string(payoffAmount)); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + return payoffAmount; + }(); singlePayment( loanKeylet, @@ -4009,7 +4019,7 @@ protected: createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); // Fails in preclaim because principal requested can't be // represented as XRP - env(createJson, ter(tecPRECISION_LOSS)); + env(createJson, ter(tecPRECISION_LOSS), THISLINE); env.close(); BEAST_EXPECT(!env.le(keylet)); @@ -4021,7 +4031,7 @@ protected: createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); // Fails in doApply because the payment is too small to be // represented as XRP. - env(createJson, ter(tecPRECISION_LOSS)); + env(createJson, ter(tecPRECISION_LOSS), THISLINE); env.close(); } @@ -4996,7 +5006,7 @@ protected: auto const keylet = keylet::loan(broker.brokerID, loanSequence); createJson = env.json(createJson, sig(sfCounterpartySignature, lender)); - env(createJson, ter(tecPRECISION_LOSS)); + env(createJson, ter(tecPRECISION_LOSS), THISLINE); env.close(startDate); auto loanPayTx = env.json( diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index 559af28a47..64ae1f30e2 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -179,11 +179,12 @@ adjustImpreciseNumber( } inline int -getVaultScale(SLE::const_ref vaultSle) +getAssetsTotalScale(SLE::const_ref vaultSle) { if (!vaultSle) return Number::minExponent - 1; // LCOV_EXCL_LINE - return vaultSle->at(sfAssetsTotal).exponent(); + return STAmount{vaultSle->at(sfAsset), vaultSle->at(sfAssetsTotal)} + .exponent(); } TER @@ -418,7 +419,8 @@ computeLoanProperties( std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, TenthBips32 managementFeeRate, - std::int32_t minimumScale); + std::int32_t minimumScale, + beast::Journal j); bool isRounded(Asset const& asset, Number const& value, std::int32_t scale); diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index cb75444a90..2e6b813255 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -451,7 +451,8 @@ tryOverpayment( paymentInterval, paymentRemaining, managementFeeRate, - loanScale); + loanScale, + j); JLOG(j.debug()) << "new periodic payment: " << newLoanProperties.periodicPayment @@ -1611,7 +1612,8 @@ computeLoanProperties( std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, TenthBips32 managementFeeRate, - std::int32_t minimumScale) + std::int32_t minimumScale, + beast::Journal j) { auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval); XRPL_ASSERT( @@ -1622,13 +1624,22 @@ computeLoanProperties( principalOutstanding, periodicRate, paymentsRemaining); auto const [totalValueOutstanding, loanScale] = [&]() { - NumberRoundModeGuard mg(Number::to_nearest); + // only round up if there should be interest + NumberRoundModeGuard mg( + periodicRate == 0 ? Number::to_nearest : Number::upward); // Use STAmount's internal rounding instead of roundToAsset, because // we're going to use this result to determine the scale for all the // other rounding. // Equation (30) from XLS-66 spec, Section A-2 Equation Glossary STAmount amount{asset, periodicPayment * paymentsRemaining}; + JLOG(j.debug()) << "computeLoanProperties:" << " Principal requested: " + << principalOutstanding + << ". Periodic payment: " << periodicPayment + << ". Payments remaining: " << paymentsRemaining + << ". Raw total value: " + << periodicPayment * paymentsRemaining + << ". Candidate total value: " << amount << std::endl; // Base the loan scale on the total value, since that's going to be // the biggest number involved (barring unusual parameters for late, @@ -1643,7 +1654,10 @@ computeLoanProperties( // We may need to truncate the total value because of the minimum // scale - amount = roundToAsset(asset, amount, loanScale, Number::to_nearest); + amount = roundToAsset(asset, amount, loanScale); + + JLOG(j.debug()) << "computeLoanProperties: Loan scale:" << loanScale + << ". Actual total value: " << amount << std::endl; return std::make_pair(amount, loanScale); }(); diff --git a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp index f3dd781bb5..4b88f82484 100644 --- a/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanBrokerDelete.cpp @@ -56,7 +56,7 @@ LoanBrokerDelete::preclaim(PreclaimContext const& ctx) if (!vault) return tefINTERNAL; // LCOV_EXCL_LINE auto const asset = vault->at(sfAsset); - auto const scale = getVaultScale(vault); + auto const scale = getAssetsTotalScale(vault); auto const rounded = roundToAsset(asset, debtTotal, scale, Number::towards_zero); diff --git a/src/xrpld/app/tx/detail/LoanDelete.cpp b/src/xrpld/app/tx/detail/LoanDelete.cpp index 87ff4d594b..fd538f3c08 100644 --- a/src/xrpld/app/tx/detail/LoanDelete.cpp +++ b/src/xrpld/app/tx/detail/LoanDelete.cpp @@ -115,7 +115,7 @@ LoanDelete::doApply() roundToAsset( vaultSle->at(sfAsset), debtTotalProxy, - getVaultScale(vaultSle), + getAssetsTotalScale(vaultSle), Number::towards_zero) == beast::zero, "ripple::LoanDelete::doApply", "last loan, remaining debt rounds to zero"); diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index da86d7b56b..c6c4b731ff 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -178,7 +178,7 @@ LoanManage::defaultLoan( // The vault may be at a different scale than the loan. Reduce rounding // errors during the accounting by rounding some of the values to that // scale. - auto const vaultScale = getVaultScale(vaultSle); + auto const vaultScale = getAssetsTotalScale(vaultSle); { // Decrease the Total Value of the Vault: @@ -242,7 +242,11 @@ LoanManage::defaultLoan( return tefBAD_LEDGER; // LCOV_EXCL_STOP } - vaultLossUnrealizedProxy -= totalDefaultAmount; + adjustImpreciseNumber( + vaultLossUnrealizedProxy, + -totalDefaultAmount, + vaultAsset, + vaultScale); } view.update(vaultSle); } @@ -250,11 +254,9 @@ LoanManage::defaultLoan( // Update the LoanBroker object: { - auto const asset = *vaultSle->at(sfAsset); - // Decrease the Debt of the LoanBroker: adjustImpreciseNumber( - brokerDebtTotalProxy, -totalDefaultAmount, asset, vaultScale); + brokerDebtTotalProxy, -totalDefaultAmount, vaultAsset, vaultScale); // Decrease the First-Loss Capital Cover Available: auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); if (coverAvailableProxy < defaultCovered) @@ -297,13 +299,20 @@ LoanManage::impairLoan( ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j) { Number const lossUnrealized = owedToVault(loanSle); + // The vault may be at a different scale than the loan. Reduce rounding + // errors during the accounting by rounding some of the values to that + // scale. + auto const vaultScale = getAssetsTotalScale(vaultSle); + // Update the Vault object(set "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - vaultLossUnrealizedProxy += lossUnrealized; + adjustImpreciseNumber( + vaultLossUnrealizedProxy, lossUnrealized, vaultAsset, vaultScale); if (vaultLossUnrealizedProxy > vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable)) { @@ -334,8 +343,14 @@ LoanManage::unimpairLoan( ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j) { + // The vault may be at a different scale than the loan. Reduce rounding + // errors during the accounting by rounding some of the values to that + // scale. + auto const vaultScale = getAssetsTotalScale(vaultSle); + // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); Number const lossReversed = owedToVault(loanSle); @@ -347,7 +362,10 @@ LoanManage::unimpairLoan( return tefBAD_LEDGER; // LCOV_EXCL_STOP } - vaultLossUnrealizedProxy -= lossReversed; + // Reverse the "paper loss" + adjustImpreciseNumber( + vaultLossUnrealizedProxy, -lossReversed, vaultAsset, vaultScale); + view.update(vaultSle); // Update the Loan object @@ -403,12 +421,14 @@ LoanManage::doApply() } else if (tx.isFlag(tfLoanImpair)) { - if (auto const ter = impairLoan(view, loanSle, vaultSle, j_)) + if (auto const ter = + impairLoan(view, loanSle, vaultSle, vaultAsset, j_)) return ter; } else if (tx.isFlag(tfLoanUnimpair)) { - if (auto const ter = unimpairLoan(view, loanSle, vaultSle, j_)) + if (auto const ter = + unimpairLoan(view, loanSle, vaultSle, vaultAsset, j_)) return ter; } diff --git a/src/xrpld/app/tx/detail/LoanManage.h b/src/xrpld/app/tx/detail/LoanManage.h index dde1023cad..abb76e8182 100644 --- a/src/xrpld/app/tx/detail/LoanManage.h +++ b/src/xrpld/app/tx/detail/LoanManage.h @@ -44,6 +44,7 @@ public: ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j); /** Helper function that might be needed by other transactors @@ -53,6 +54,7 @@ public: ApplyView& view, SLE::ref loanSle, SLE::ref vaultSle, + Asset const& vaultAsset, beast::Journal j); TER diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp index 43f19743a7..56e8ada99e 100644 --- a/src/xrpld/app/tx/detail/LoanPay.cpp +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -305,7 +305,7 @@ LoanPay::doApply() // change will be discarded. if (loanSle->isFlag(lsfLoanImpaired)) { - LoanManage::unimpairLoan(view, loanSle, vaultSle, j_); + LoanManage::unimpairLoan(view, loanSle, vaultSle, asset, j_); } LoanPaymentType const paymentType = [&tx]() { @@ -379,7 +379,7 @@ LoanPay::doApply() // The vault may be at a different scale than the loan. Reduce rounding // errors during the payment by rounding some of the values to that scale. - auto const vaultScale = assetsTotalProxy.value().exponent(); + auto const vaultScale = getAssetsTotalScale(vaultSle); auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid; diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index 838e774cae..b92b51d3b7 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -383,7 +383,7 @@ LoanSet::doApply() auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); - auto const vaultScale = getVaultScale(vaultSle); + auto const vaultScale = getAssetsTotalScale(vaultSle); if (vaultAvailableProxy < principalRequested) { JLOG(j_.warn()) @@ -404,7 +404,8 @@ LoanSet::doApply() paymentInterval, paymentTotal, TenthBips16{brokerSle->at(sfManagementFeeRate)}, - vaultScale); + vaultScale, + j_); // Check that relevant values won't lose precision. This is mostly only // relevant for IOU assets. @@ -440,7 +441,10 @@ LoanSet::doApply() { // LCOV_EXCL_START JLOG(j_.warn()) - << "Computed loan properties are invalid. Does not compute."; + << "Computed loan properties are invalid. Does not compute." + << " Management fee: " << properties.managementFeeOwedToBroker + << ". Total Value: " << properties.totalValueOutstanding + << ". PeriodicPayment: " << properties.periodicPayment; return tecINTERNAL; // LCOV_EXCL_STOP }