From e1939d0d20ac144c51729fbe85e5db8824e23c87 Mon Sep 17 00:00:00 2001 From: Ed Hennis Date: Thu, 2 Oct 2025 21:01:19 -0400 Subject: [PATCH] Convert LoanSet to precomputed all values - Tests are not expected to pass --- .../xrpl/protocol/detail/ledger_entries.macro | 7 +- include/xrpl/protocol/detail/sfields.macro | 2 + src/test/app/Loan_test.cpp | 2 + src/xrpld/app/misc/LendingHelpers.h | 289 ++++++++++++------ src/xrpld/app/tx/detail/LoanManage.cpp | 3 + src/xrpld/app/tx/detail/LoanSet.cpp | 103 ++++--- 6 files changed, 265 insertions(+), 141 deletions(-) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 99e16e6b70..56497aa858 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -561,11 +561,12 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ {sfPaymentInterval, soeREQUIRED}, {sfGracePeriod, soeREQUIRED}, {sfPeriodicPayment, soeREQUIRED}, - {sfPreviousPaymentDate, soeREQUIRED}, + {sfPreviousPaymentDate, soeDEFAULT}, {sfNextPaymentDueDate, soeREQUIRED}, - {sfPaymentRemaining, soeREQUIRED}, - {sfPrincipalOutstanding, soeREQUIRED}, + {sfPaymentRemaining, soeDEFAULT}, + {sfPrincipalOutstanding, soeDEFAULT}, {sfTotalValueOutstanding, soeDEFAULT}, + {sfInterestOwed, soeDEFAULT}, // Based on the original principal borrowed, used for // rounding calculated values so they are all on a // consistent scale - that is, they all have the same diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 3c3dda69e0..2fca180b7b 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -241,6 +241,8 @@ TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 13) TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14) TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15) TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) +TYPED_SFIELD(sfReferencePrincipal, NUMBER, 17) +TYPED_SFIELD(sfInterestOwed, NUMBER, 18) // int32 TYPED_SFIELD(sfLoanScale, INT32, 1) diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index de9c257525..495c6a405d 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -402,6 +402,7 @@ class Loan_test : public beast::unit_test::suite BrokerInfo const& broker, LoanState const& state) { +#if LOANCOMPLETE if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); BEAST_EXPECT(brokerSle)) { @@ -430,6 +431,7 @@ class Loan_test : public beast::unit_test::suite } } } +#endif return true; } diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index b0114c4921..c1a18106e4 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -31,6 +31,16 @@ struct PreflightContext; bool checkLendingProtocolDependencies(PreflightContext const& ctx); +struct PaymentParts +{ + Number rawInterest; + Number rawPrincipal; + Number roundedInterest; + Number roundedPrincipal; + Number roundedPayment; + bool final = false; +}; + namespace detail { // These functions should rarely be used directly. More often, the ultimate // result needs to be roundToAsset'd. @@ -74,11 +84,104 @@ minusManagementFee(Number const& value, TenthBips32 managementFeeRate) return tenthBipsOfValue(value, tenthBipsPerUnity - managementFeeRate); } +template +PaymentParts +computePaymentParts( + A const& asset, + std::int32_t scale, + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& referencePrincipal, + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining) +{ + /* + * This function is derived from the XLS-66 spec, section 3.2.4.1.1 (Regular + * Payment) + */ + XRPL_ASSERT_PARTS( + isRounded(asset, totalValueOutstanding, scale) && + isRounded(asset, principalOutstanding, scale), + "ripple::detail::computePaymentParts", + "Outstanding values are rounded"); + auto const roundedPeriodicPayment = + roundToAsset(asset, periodicPayment, scale, Number::upward); + if (paymentRemaining == 1 || totalValueOutstanding <= periodicPayment) + { + // If there's only one payment left, we need to pay off the principal. + // + // The totalValueOutstanding should never be less than the + // periodicPayment until the last scheduled payment, but if it ever is, + // make it the last payment. + Number rawInterest = totalValueOutstanding - referencePrincipal; + Number roundedInterest = totalValueOutstanding - principalOutstanding; + + // This is only expected to be true on the last payment + XRPL_ASSERT_PARTS( + rawInterest + referencePrincipal == + roundedInterest + principalOutstanding, + "ripple::detail::computePaymentParts", + "last payment is complete"); + + Number const interest = totalValueOutstanding - principalOutstanding; + return { + .rawInterest = rawInterest, + .rawPrincipal = referencePrincipal, + .roundedInterest = roundedInterest, + .roundedPrincipal = principalOutstanding, + .roundedPayment = roundedPeriodicPayment, + .final = true}; + } + /* + * From the spec, once the periodicPayment is computed: + * + * The principal and interest portions can be derived as follows: + * interest = principalOutstanding * periodicRate + * principal = periodicPayment - interest + * + * Because those values deal with funds, they need to be rounded. + */ + Number const rawInterest = referencePrincipal * periodicRate; + Number const rawPrincipal = periodicPayment - rawInterest; + XRPL_ASSERT_PARTS( + rawInterest >= 0, + "ripple::detail::computePaymentParts", + "valid raw interest"); + XRPL_ASSERT_PARTS( + rawPrincipal > 0 && rawPrincipal <= referencePrincipal, + "ripple::detail::computePaymentParts", + "valid raw principal"); + + Number const roundedInterest = + roundToAsset(asset, rawInterest, scale, Number::upward); + Number const roundedPrincipal = roundedPeriodicPayment - roundedInterest; + XRPL_ASSERT_PARTS( + roundedInterest >= 0, + "ripple::detail::computePaymentParts", + "valid rounded interest"); + XRPL_ASSERT_PARTS( + roundedPrincipal >= 0 && roundedPrincipal <= principalOutstanding, + "ripple::detail::computePaymentParts", + "valid rounded principal"); + XRPL_ASSERT_PARTS( + isRounded(asset, roundedPrincipal, scale), + "ripple::detail::computePaymentParts", + "principal is rounded"); + + return { + .rawInterest = rawInterest, + .rawPrincipal = rawPrincipal, + .roundedInterest = roundedInterest, + .roundedPrincipal = roundedPrincipal, + .roundedPayment = roundedPeriodicPayment}; +} + } // namespace detail template Number -valueMinusManagementFee( +valueMinusFee( A const& asset, Number const& value, TenthBips32 managementFeeRate, @@ -88,6 +191,90 @@ valueMinusManagementFee( asset, detail::minusManagementFee(value, managementFeeRate), scale); } +struct LoanProperties +{ + Number periodicPayment; + Number totalValueOutstanding; + Number interestOwedToVault; + std::int32_t loanScale; + Number firstPaymentPrincipal; +}; + +template +LoanProperties +computeLoanProperties( + A const& asset, + Number const& principalOutstanding, + Number const& referencePrincipal, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentsRemaining, + TenthBips32 managementFeeRate) +{ + auto const periodicRate = + detail::loanPeriodicRate(interestRate, paymentInterval); + auto const periodicPayment = detail::loanPeriodicPayment( + principalOutstanding, periodicRate, paymentsRemaining); + Number const totalValueOutstanding = [&]() { + NumberRoundModeGuard mg(Number::to_nearest); + // 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. + return STAmount{ + asset, + /* + * This formula is from the XLS-66 spec, section 3.2.4.2 (Total + * Loan Value Calculation), specifically "totalValueOutstanding + * = ..." + */ + periodicPayment * paymentsRemaining}; + }(); + // Base the loan scale on the total value, since that's going to be the + // biggest number involved + auto const loanScale = totalValueOutstanding.exponent(); + + auto const firstPaymentPrincipal = [&]() { + // Compute the unrounded parts for the first payment. Ensure that the + // principal payment will actually change the principal. + auto const paymentParts = detail::computePaymentParts( + asset, + loanScale, + totalValueOutstanding, + principalOutstanding, + referencePrincipal, + periodicPayment, + periodicRate, + paymentsRemaining); + + // We only care about the unrounded principal part. It needs to be large + // enough that it will affect the reference principal. + auto const remaining = referencePrincipal - paymentParts.rawPrincipal; + if (remaining == referencePrincipal) + // No change, so the first payment effectively pays no principal. + // Whether that's a problem is left to the caller. + return Number{0}; + return paymentParts.rawPrincipal; + }(); + + auto const interestOwedToVault = valueMinusFee( + asset, + /* + * This formula is from the XLS-66 spec, section 3.2.4.2 (Total Loan + * Value Calculation), specifically "totalInterestOutstanding = ..." + */ + totalValueOutstanding - principalOutstanding, + managementFeeRate, + loanScale); + + return LoanProperties{ + .periodicPayment = periodicPayment, + .totalValueOutstanding = totalValueOutstanding, + .interestOwedToVault = interestOwedToVault, + .loanScale = loanScale, + .firstPaymentPrincipal = firstPaymentPrincipal}; +} + +#if LOANCOMPLETE inline Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) { @@ -211,6 +398,7 @@ loanTotalInterestOutstanding( paymentInterval, paymentsRemaining)); } +#endif template Number @@ -220,10 +408,11 @@ loanInterestOutstandingMinusFee( TenthBips32 managementFeeRate, std::int32_t scale) { - return valueMinusManagementFee( + return valueMinusFee( asset, totalInterestOutstanding, managementFeeRate, scale); } +#if LOANCOMPLETE template Number loanInterestOutstandingMinusFee( @@ -247,6 +436,7 @@ loanInterestOutstandingMinusFee( managementFeeRate, scale); } +#endif template Number @@ -278,98 +468,6 @@ isRounded(A const& asset, Number const& value, std::int32_t scale) roundToAsset(asset, value, scale, Number::upward) == value; } -struct PaymentParts -{ - Number interest; - Number principal; - Number fee; -}; - -template -PaymentParts -computePaymentParts( - A const& asset, - std::int32_t scale, - Number const& totalValueOutstanding, - Number const& principalOutstanding, - Number const& periodicPaymentAmount, - Number const& serviceFee, - Number const& periodicRate, - std::uint32_t paymentRemaining) -{ - /* - * This function is derived from the XLS-66 spec, section 3.2.4.1.1 (Regular - * Payment) - */ - XRPL_ASSERT_PARTS( - isRounded(asset, totalValueOutstanding, scale) && - isRounded(asset, principalOutstanding, scale) && - isRounded(asset, periodicPaymentAmount, scale), - "ripple::computePaymentParts", - "Asset values are rounded"); - Number const roundedFee = roundToAsset(asset, serviceFee, scale); - if (paymentRemaining == 1 || periodicPaymentAmount > totalValueOutstanding) - { - // If there's only one payment left, we need to pay off the principal. - Number const interest = totalValueOutstanding - principalOutstanding; - return { - .interest = interest, - .principal = principalOutstanding, - .fee = roundedFee}; - } - /* - * From the spec, once the periodicPayment is computed: - * - * The principal and interest portions can be derived as follows: - * interest = principalOutstanding * periodicRate - * principal = periodicPayment - interest - * - * Because those values deal with funds, they need to be rounded. - */ - Number const interest = roundToAsset( - asset, principalOutstanding * periodicRate, scale, Number::upward); - XRPL_ASSERT( - interest >= 0, - "ripple::detail::computePeriodicPayment : valid interest"); - - // To compute the principal using the above formulas, use the rounded - // payment amount, ensuring that some principal is paid regardless of any - // other results. - auto const roundedPayment = [&]() { - auto roundedPayment = - roundToAsset(asset, periodicPaymentAmount, scale, Number::upward); - if (roundedPayment > interest) - return roundedPayment; - auto newPayment = roundedPayment; - if (asset.native() || !asset.template holds()) - { - // integral types, just add one - ++newPayment; - } - else - { - // Non-integral types: IOU. Add "dust" that will not be lost in - // rounding. - auto const epsilon = Number{1, scale - 14}; - newPayment += epsilon; - } - roundedPayment = roundToAsset(asset, newPayment, scale); - XRPL_ASSERT_PARTS( - roundedPayment == newPayment, - "ripple::computePaymentParts", - "epsilon preserved in rounding"); - return roundedPayment; - }(); - Number const principal = - roundToAsset(asset, roundedPayment - interest, scale); - XRPL_ASSERT_PARTS( - principal > 0 && principal <= principalOutstanding, - "ripple::computePaymentParts", - "valid principal"); - - return {.interest = interest, .principal = principal, .fee = roundedFee}; -} - // This structure is explained in the XLS-66 spec, section 3.2.4.4 (Failure // Conditions) struct LoanPaymentParts @@ -420,6 +518,8 @@ handleLatePayment( STAmount const& amount, beast::Journal j) { + return Unexpected(temDISABLED); +#if LOANCOMPLETE if (!hasExpired(view, nextDueDateProxy)) return Unexpected(tesSUCCESS); @@ -466,6 +566,7 @@ handleLatePayment( .interestPaid = late.interest, .valueChange = latePaymentInterest, .feePaid = late.fee}; +#endif } /* Handle possible full payments. diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index ac5da7f7dc..ff83134ac7 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -361,6 +361,8 @@ LoanManage::unimpairLoan( TER LoanManage::doApply() { + return temDISABLED; +#if LOANCOMPLETE auto const& tx = ctx_.tx; auto& view = ctx_.view(); @@ -435,6 +437,7 @@ LoanManage::doApply() } return tesSUCCESS; +#endif } //------------------------------------------------------------------------------ diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index 71619c4157..447eec5967 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -285,10 +285,7 @@ LoanSet::doApply() { return tefBAD_LEDGER; // LCOV_EXCL_LINE } - auto const principalRequested = [&](Number const& requested) { - return roundToAsset(vaultAsset, requested, requested.exponent()); - }(tx[sfPrincipalRequested]); - auto const loanScale = principalRequested.exponent(); + auto const principalRequested = tx[sfPrincipalRequested]; if (auto const assetsAvailable = vaultSle->at(sfAssetsAvailable); assetsAvailable < principalRequested) @@ -300,53 +297,68 @@ LoanSet::doApply() TenthBips32 const interestRate{tx[~sfInterestRate].value_or(0)}; - auto const originationFee = roundToAsset( - vaultAsset, tx[~sfLoanOriginationFee].value_or(Number{}), loanScale); - auto const loanAssetsToBorrower = principalRequested - originationFee; - auto const paymentInterval = tx[~sfPaymentInterval].value_or(defaultPaymentInterval); auto const paymentTotal = tx[~sfPaymentTotal].value_or(defaultPaymentTotal); - auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval); - auto const periodicPayment = loanPeriodicPayment( - vaultAsset, principalRequested, periodicRate, paymentTotal, loanScale); - - auto const totalValueOutstanding = loanTotalValueOutstanding( - vaultAsset, loanScale, periodicPayment, paymentTotal); + auto const properties = computeLoanProperties( + vaultAsset, + principalRequested, + principalRequested, + interestRate, + paymentInterval, + paymentTotal, + TenthBips32{brokerSle->at(sfManagementFeeRate)}); + if (properties.firstPaymentPrincipal <= 0) { - // Check that some principal is paid each period. Since the first - // payment pays the least principal, if it's good, they'll all be good. - auto const paymentParts = computePaymentParts( - vaultAsset, - loanScale, - totalValueOutstanding, - principalRequested, - periodicPayment, - tx[~sfLoanServiceFee].value_or(Number{}), - periodicRate, - paymentTotal); - - if (paymentParts.principal <= 0) - { - JLOG(j_.warn()) << "Loan is unable to pay principal."; - return tecLIMIT_EXCEEDED; - } + // Check that some reference principal is paid each period. Since the + // first payment pays the least principal, if it's good, they'll all be + // good. Note that the outstanding principal is rounded, and may not + // change right away. + JLOG(j_.warn()) << "Loan is unable to pay principal."; + return tecLIMIT_EXCEEDED; + } + // Check that the other computed values are valid + if (properties.interestOwedToVault < 0 || + properties.totalValueOutstanding <= 0 || + properties.periodicPayment <= 0) + { + // LCOV_EXCL_START + JLOG(j_.warn()) + << "Computed loan properties are invalid. Does not compute."; + return tecINTERNAL; + // LCOV_EXCL_STOP } - TenthBips32 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; + // Check that relevant values won't lose precision + { + static std::map> const + valueFields{ + {"Principal Requested", ~sfPrincipalRequested}, + {"Origination fee", ~sfLoanOriginationFee}, + {"Service fee", ~sfLoanServiceFee}, + {"Late Payment fee", ~sfLatePaymentFee}, + {"Close Payment fee", ~sfClosePaymentFee} + // Overpayment fee is really a rate. Don't include it. + }; + for (auto const& [name, field] : valueFields) + { + if (auto const value = tx[field]; + value && !isRounded(vaultAsset, *value, properties.loanScale)) + { + JLOG(j_.warn()) << name << " has too much precision."; + return tecPRECISION_LOSS; + } + } + } + auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); - auto const totalInterestOwedToVault = [&]() { - auto const totalInterestOutstanding = loanTotalInterestOutstanding( - principalRequested, totalValueOutstanding); + auto const loanAssetsToBorrower = principalRequested - originationFee; - return loanInterestOutstandingMinusFee( - vaultAsset, totalInterestOutstanding, managementFeeRate, loanScale); - }(); - - auto const newDebtTotal = brokerSle->at(sfDebtTotal) + principalRequested + - totalInterestOwedToVault; + auto const newDebtDelta = + principalRequested + properties.interestOwedToVault; + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); debtMaximum != 0 && debtMaximum < newDebtTotal) { @@ -458,7 +470,10 @@ LoanSet::doApply() setLoanField(~sfGracePeriod, defaultGracePeriod); // Set dynamic / computed fields to their initial values loan->at(sfPrincipalOutstanding) = principalRequested; - loan->at(sfTotalValueOutstanding) = totalValueOutstanding; + loan->at(sfReferencePrincipal) = principalRequested; + loan->at(sfPeriodicPayment) = properties.periodicPayment; + loan->at(sfTotalValueOutstanding) = properties.totalValueOutstanding; + loan->at(sfInterestOwed) = properties.interestOwedToVault; loan->at(sfPreviousPaymentDate) = 0; loan->at(sfNextPaymentDueDate) = startDate + paymentInterval; loan->at(sfPaymentRemaining) = paymentTotal; @@ -466,7 +481,7 @@ LoanSet::doApply() // Update the balances in the vault vaultSle->at(sfAssetsAvailable) -= principalRequested; - vaultSle->at(sfAssetsTotal) += totalInterestOwedToVault; + vaultSle->at(sfAssetsTotal) += properties.interestOwedToVault; XRPL_ASSERT_PARTS( *vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal), "ripple::LoanSet::doApply", @@ -474,7 +489,7 @@ LoanSet::doApply() view.update(vaultSle); // Update the balances in the loan broker - brokerSle->at(sfDebtTotal) += principalRequested + totalInterestOwedToVault; + brokerSle->at(sfDebtTotal) += newDebtDelta; // The broker's owner count is solely for the number of outstanding loans, // and is distinct from the broker's pseudo-account's owner count adjustOwnerCount(view, brokerSle, 1, j_);