diff --git a/include/xrpl/protocol/Asset.h b/include/xrpl/protocol/Asset.h index 4438106738..7a5dc67911 100644 --- a/include/xrpl/protocol/Asset.h +++ b/include/xrpl/protocol/Asset.h @@ -103,6 +103,12 @@ public: return holds() && get().native(); } + bool + integral() const + { + return !holds() || get().native(); + } + friend constexpr bool operator==(Asset const& lhs, Asset const& rhs); diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 2854e48534..0c86bf1f95 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -578,10 +578,10 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ // - PrincipalOutstanding: The rounded portion of the // TotalValueOutstanding that is from the principal borrowed. // - // - InterestOwed: The rounded portion of the TotalValueOutstanding that - // represents interest specifically owed to the vault. This may be less - // than the interest owed by the borrower, because it excludes the - // expected value of broker management fees. + // - ManagementFeeOutstanding: The rounded portion of the + // TotalValueOutstanding that represents management fees + // specifically owed to the broker based on the initial + // loan parameters. // // There are additional values that can be computed from these: // @@ -589,9 +589,9 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ // The total amount of interest still pending on the loan, // independent of management fees. // - // - ManagementFeeOwed = InterestOutstanding - InterestOwed - // The amount of the total interest that will be sent to the - // broker as management fees. + // - InterestOwedToVault = InterestOutstanding - ManagementFeeOutstanding + // The amount of the total interest that is owed to the vault, and + // will be sent to as part of a payment. // // - TrueTotalLoanValue = PaymentRemaining * PeriodicPayment // The unrounded true total value of the loan. @@ -603,18 +603,23 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ // TrueTotalPrincipalOutstanding // The unrounded true total interest remaining. // + // - TrueTotalManagementFeeOutstanding = TrueTotalInterestOutstanding * + // LoanBroker.ManagementFeeRate + // The unrounded true total fee still owed to the broker. + // // Note the the "True" values may differ significantly from the tracked // rounded values. - {sfPaymentRemaining, soeDEFAULT}, - {sfPeriodicPayment, soeREQUIRED}, - {sfPrincipalOutstanding, soeDEFAULT}, - {sfTotalValueOutstanding, soeDEFAULT}, - {sfInterestOwed, soeDEFAULT}, - // Based on the original principal borrowed, used for + {sfPaymentRemaining, soeDEFAULT}, + {sfPeriodicPayment, soeREQUIRED}, + {sfPrincipalOutstanding, soeDEFAULT}, + {sfTotalValueOutstanding, soeDEFAULT}, + {sfManagementFeeOutstanding, soeDEFAULT}, + // Based on the computed total value at creation, used for // rounding calculated values so they are all on a // consistent scale - that is, they all have the same - // number of decimal places after the decimal point. - {sfLoanScale, soeDEFAULT}, + // number of digits after the decimal point (excluding + // trailing zeros). + {sfLoanScale, soeDEFAULT}, })) #undef EXPAND diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 1911c7e31d..086cda0e08 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -241,7 +241,7 @@ TYPED_SFIELD(sfPrincipalOutstanding, NUMBER, 13) TYPED_SFIELD(sfPrincipalRequested, NUMBER, 14) TYPED_SFIELD(sfTotalValueOutstanding, NUMBER, 15) TYPED_SFIELD(sfPeriodicPayment, NUMBER, 16) -TYPED_SFIELD(sfInterestOwed, NUMBER, 17) +TYPED_SFIELD(sfManagementFeeOutstanding, NUMBER, 17) // int32 TYPED_SFIELD(sfLoanScale, INT32, 1) diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 4248cc0f93..093e41f835 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -115,10 +115,10 @@ class Loan_test : public beast::unit_test::suite std::int32_t const loanScale = 0; Number totalValue = 0; Number principalOutstanding = 0; - Number interestOwed = 0; + Number managementFeeOutstanding = 0; Number periodicPayment = 0; std::uint32_t flags = 0; - std::uint32_t paymentInterval = 0; + std::uint32_t const paymentInterval = 0; TenthBips32 const interestRate{}; }; @@ -239,7 +239,7 @@ class Loan_test : public beast::unit_test::suite Number const& loanScale, Number const& totalValue, Number const& principalOutstanding, - Number const& interestOwed, + Number const& managementFeeOutstanding, Number const& periodicPayment, std::uint32_t flags) const { @@ -260,16 +260,20 @@ class Loan_test : public beast::unit_test::suite loan->at(sfTotalValueOutstanding) == totalValue); env.test.BEAST_EXPECT( loan->at(sfPrincipalOutstanding) == principalOutstanding); - env.test.BEAST_EXPECT(loan->at(sfInterestOwed) == interestOwed); + env.test.BEAST_EXPECT( + loan->at(sfManagementFeeOutstanding) == + managementFeeOutstanding); env.test.BEAST_EXPECT( loan->at(sfPeriodicPayment) == periodicPayment); env.test.BEAST_EXPECT(loan->at(sfFlags) == flags); + auto const ls = calculateRoundedLoanState(loan); + auto const interestRate = TenthBips32{loan->at(sfInterestRate)}; auto const paymentInterval = loan->at(sfPaymentInterval); checkBroker( principalOutstanding, - interestOwed, + ls.interestDue, interestRate, paymentInterval, paymentRemaining, @@ -288,7 +292,7 @@ class Loan_test : public beast::unit_test::suite { env.test.BEAST_EXPECT( vaultSle->at(sfLossUnrealized) == - principalOutstanding + interestOwed); + totalValue - managementFeeOutstanding); } else { @@ -311,7 +315,7 @@ class Loan_test : public beast::unit_test::suite state.loanScale, state.totalValue, state.principalOutstanding, - state.interestOwed, + state.managementFeeOutstanding, state.periodicPayment, state.flags); }; @@ -388,7 +392,8 @@ class Loan_test : public beast::unit_test::suite .loanScale = loan->at(sfLoanScale), .totalValue = loan->at(sfTotalValueOutstanding), .principalOutstanding = loan->at(sfPrincipalOutstanding), - .interestOwed = loan->at(sfInterestOwed), + .managementFeeOutstanding = + loan->at(sfManagementFeeOutstanding), .periodicPayment = loan->at(sfPeriodicPayment), .flags = loan->at(sfFlags), .paymentInterval = loan->at(sfPaymentInterval), @@ -403,6 +408,19 @@ class Loan_test : public beast::unit_test::suite BEAST_EXPECT( state.principalOutstanding.exponent() == state.loanScale); BEAST_EXPECT(state.paymentInterval == 600); + BEAST_EXPECT( + state.totalValue == + roundToAsset( + broker.asset, + state.periodicPayment * state.paymentRemaining, + state.loanScale)); + BEAST_EXPECT( + state.managementFeeOutstanding == + computeFee( + broker.asset, + state.totalValue - state.principalOutstanding, + managementFeeRateParameter, + state.loanScale)); verifyLoanStatus(state); @@ -429,7 +447,7 @@ class Loan_test : public beast::unit_test::suite auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + - state.principalOutstanding + state.interestOwed; + state.totalValue - state.managementFeeOutstanding; if (unrealizedLoss > assetsUnavailable) { @@ -627,7 +645,7 @@ class Loan_test : public beast::unit_test::suite auto state = getCurrentState(env, broker, keylet, verifyLoanStatus); auto const loanProperties = computeLoanProperties( - broker.asset, + broker.asset.raw(), state.principalOutstanding, state.interestRate, state.paymentInterval, @@ -641,7 +659,7 @@ class Loan_test : public beast::unit_test::suite principalRequest.exponent(), loanProperties.totalValueOutstanding, principalRequest, - loanProperties.interestOwedToVault, + loanProperties.managementFeeOwedToBroker, loanProperties.periodicPayment, loanFlags | 0); @@ -698,7 +716,7 @@ class Loan_test : public beast::unit_test::suite principalRequest.exponent(), loanProperties.totalValueOutstanding, principalRequest, - loanProperties.interestOwedToVault, + loanProperties.managementFeeOwedToBroker, loanProperties.periodicPayment, loanFlags | 0); @@ -1189,7 +1207,7 @@ class Loan_test : public beast::unit_test::suite brokerSle->at(sfDebtTotal), coverRateMinParameter), coverRateLiquidationParameter), - state.principalOutstanding + state.interestOwed), + state.totalValue - state.managementFeeOutstanding), state.loanScale); return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); } @@ -1280,7 +1298,7 @@ class Loan_test : public beast::unit_test::suite state.paymentRemaining = 0; state.totalValue = 0; state.principalOutstanding = 0; - state.interestOwed = 0; + state.managementFeeOutstanding = 0; verifyLoanStatus(state); // Once a loan is defaulted, it can't be managed @@ -1350,7 +1368,7 @@ class Loan_test : public beast::unit_test::suite state.paymentRemaining = 0; state.principalOutstanding = 0; state.totalValue = 0; - state.interestOwed = 0; + state.managementFeeOutstanding = 0; state.previousPaymentDate = state.nextPaymentDate + state.paymentInterval * (numPayments - 1); verifyLoanStatus(state); @@ -1629,37 +1647,55 @@ class Loan_test : public beast::unit_test::suite testcase << "\tPayment components: " << "Payments remaining, rawInterest, rawPrincipal, " - "roundedInterest, " - "roundedPrincipal, roundedPayment, final, extra"; + "rawMFee, roundedInterest, roundedPrincipal, " + "roundedMFee, final, extra"; + + auto const serviceFee = broker.asset(2); + + BEAST_EXPECT( + roundedPeriodicPayment == + roundToScale( + broker.asset( + Number(8333457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); + // 83334570.01162141 + // Include the service fee + STAmount const totalDue = roundToScale( + roundedPeriodicPayment + serviceFee, + state.loanScale, + Number::upward); + // Only check the first payment since the rounding + // may drift as payments are made + BEAST_EXPECT( + totalDue == + roundToScale( + broker.asset( + Number(8533457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); + + { + auto const raw = calculateRawLoanState( + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRateParameter); + auto const rounded = calculateRoundedLoanState( + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding); + testcase + << "\tLoan starting state: " << state.paymentRemaining + << ", " << raw.interestDue << ", " + << raw.principalOutstanding << ", " + << raw.managementFeeDue << ", " << rounded.interestDue + << ", " << rounded.principalOutstanding << ", " + << rounded.managementFeeDue; + } while (state.paymentRemaining > 0) { - auto const serviceFee = broker.asset(2); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - roundedPeriodicPayment == - roundToScale( - broker.asset( - Number(8333457001162141, -14), Number::upward), - state.loanScale, - Number::upward)); - // 83334570.01162141 - // Include the service fee - STAmount const totalDue = roundToScale( - roundedPeriodicPayment + serviceFee, - state.loanScale, - Number::upward); - // Only check the first payment since the rounding - // may drift as payments are made - BEAST_EXPECT( - totalDue == - roundToScale( - broker.asset( - Number(8533457001162141, -14), Number::upward), - state.loanScale, - Number::upward)); - // Try to pay a little extra to show that it's _not_ // taken STAmount const transactionAmount = @@ -1675,32 +1711,48 @@ class Loan_test : public beast::unit_test::suite Number::upward)); // Compute the expected principal amount - auto const paymentComponents = - detail::computePaymentComponents( - broker.asset, - state.loanScale, - state.totalValue, - state.principalOutstanding, - state.periodicPayment, - periodicRate, - state.paymentRemaining); + auto const paymentComponents = computePaymentComponents( + broker.asset.raw(), + state.loanScale, + state.totalValue, + state.principalOutstanding, + state.managementFeeOutstanding, + state.periodicPayment, + periodicRate, + state.paymentRemaining, + managementFeeRateParameter); testcase << "\tPayment components: " << state.paymentRemaining << ", " << paymentComponents.rawInterest << ", " << paymentComponents.rawPrincipal << ", " + << paymentComponents.rawManagementFee << ", " << paymentComponents.roundedInterest << ", " << paymentComponents.roundedPrincipal << ", " - << paymentComponents.roundedPayment << ", " - << paymentComponents.final << ", " - << paymentComponents.extra; + << paymentComponents.roundedManagementFee << ", " + << (paymentComponents.final ? "true" : "false") << ", " + << (paymentComponents.extra ? "true" : "false"); auto const totalDueAmount = STAmount{ broker.asset, - paymentComponents.roundedPayment + serviceFee.number()}; + paymentComponents.roundedPrincipal + + paymentComponents.roundedInterest + + paymentComponents.roundedManagementFee + + serviceFee.number()}; + // Due to the rounding algorithms to keep the interest and + // principal in sync with "true" values, the computed amount + // may be a little less than the rounded fixed payment + // amount. For integral types, the difference should be < 3 + // (1 unit for each of the interest and management fee). For + // IOUs, the difference should be after the 8th digit. + Number const diff = totalDue - totalDueAmount; BEAST_EXPECT( - paymentComponents.final || totalDue == totalDueAmount); + paymentComponents.final || diff == beast::zero || + (diff > beast::zero && + ((broker.asset.raw().integral() && + (static_cast(diff) < 3)) || + (totalDue.exponent() - diff.exponent() > 8)))); BEAST_EXPECT( paymentComponents.roundedInterest >= Number(0)); @@ -1728,18 +1780,12 @@ class Loan_test : public beast::unit_test::suite state.principalOutstanding); BEAST_EXPECT( paymentComponents.final || - paymentComponents.rawPrincipal + - paymentComponents.rawInterest == - state.periodicPayment); - BEAST_EXPECT( - paymentComponents.final || - ((roundedPeriodicPayment >= - (paymentComponents.roundedPrincipal + - paymentComponents.roundedInterest)) && - (roundedPeriodicPayment - - (paymentComponents.roundedPrincipal + - paymentComponents.roundedInterest) < - 3))); + (state.periodicPayment.exponent() - + (paymentComponents.rawPrincipal + + paymentComponents.rawInterest + + paymentComponents.rawManagementFee - + state.periodicPayment) + .exponent()) > 14); auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); @@ -1775,21 +1821,18 @@ class Loan_test : public beast::unit_test::suite if (paymentComponents.final) { state.paymentRemaining = 0; - state.interestOwed = 0; } else { state.nextPaymentDate += state.paymentInterval; - state.interestOwed -= valueMinusFee( - broker.asset.raw(), - paymentComponents.roundedInterest, - managementFeeRateParameter, - state.loanScale); } state.principalOutstanding -= paymentComponents.roundedPrincipal; + state.managementFeeOutstanding -= + paymentComponents.roundedManagementFee; state.totalValue -= paymentComponents.roundedPrincipal + - paymentComponents.roundedInterest; + paymentComponents.roundedInterest + + paymentComponents.roundedManagementFee; verifyLoanStatus(state); } diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index 50f385d1c0..475cc39ca7 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -34,22 +34,38 @@ checkLendingProtocolDependencies(PreflightContext const& ctx); Number loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval); -// This structure is used internally to compute the breakdown of a -// single loan payment +/// Ensure the periodic payment is always rounded consistently +template +Number +roundPeriodicPayment( + A const& asset, + Number const& periodicPayment, + std::int32_t scale) +{ + return roundToAsset(asset, periodicPayment, scale, Number::upward); +} + +/// This structure is used internally to compute the breakdown of a +/// single loan payment struct PaymentComponents { Number rawInterest; Number rawPrincipal; + Number rawManagementFee; Number roundedInterest; Number roundedPrincipal; - // We may not need roundedPayment - Number roundedPayment; + // roundedManagementFee is explicitly on for the portion of the pre-computed + // periodic payment that goes toward the Broker's management fee, which is + // tracked by sfManagementFeeOutstanding + Number roundedManagementFee; + //// We may not need roundedPayment + // Number roundedPayment; bool final = false; bool extra = false; }; -// This structure is explained in the XLS-66 spec, section 3.2.4.4 (Failure -// Conditions) +/// This structure is explained in the XLS-66 spec, section 3.2.4.4 (Failure +/// Conditions) struct LoanPaymentParts { /// principal_paid is the amount of principal that the payment covered. @@ -63,8 +79,12 @@ struct LoanPaymentParts * This is 0 for regular payments. */ Number valueChange; - /// fee_paid is the amount of fee that the payment covered. - Number feeToPay; + /// managementFeePaid is amount of fee that is tracked by + /// sfManagementFeeOutstanding + Number managementFeePaid; + /// extraFeePaid is the amount of fee that the payment covered not tracked + /// by sfManagementFeeOutstanding. + Number extraFeePaid; LoanPaymentParts& operator+=(LoanPaymentParts const& other) @@ -72,22 +92,114 @@ struct LoanPaymentParts principalPaid += other.principalPaid; interestPaid += other.interestPaid; valueChange += other.valueChange; - feeToPay += other.feeToPay; + extraFeePaid += other.extraFeePaid; + managementFeePaid += other.managementFeePaid; return *this; } }; -/// Ensure the periodic payment is always rounded consistently +/** This structure describes the initial "computed" properties of a loan. + * + * It is used at loan creation and when the terms of a loan change, such as + * after an overpayment. + */ +struct LoanProperties +{ + Number periodicPayment; + Number totalValueOutstanding; + Number managementFeeOwedToBroker; + std::int32_t loanScale; + Number firstPaymentPrincipal; +}; + +/** This structure captures the current state of a loan and all the + relevant parts. + + Whether the values are raw (unrounded) or rounded will + depend on how it was computed. + + Many of the fields can be derived from each other, but they're all provided + here to reduce code duplication and possible mistakes. + e.g. + * interestOutstanding = valueOutstanding - principalOutstanding + * interestDue = interestOutstanding - managementFeeDue + */ +struct LoanState +{ + /// Total value still due to be paid by the borrower. + Number valueOutstanding; + /// Prinicipal still due to be paid by the borrower. + Number principalOutstanding; + /// Interest still due to be paid by the borrower. + Number interestOutstanding; + /// Interest still due to be paid TO the Vault. + // This is a portion of interestOutstanding + Number interestDue; + /// Management fee still due to be paid TO the broker. + // This is a portion of interestOutstanding + Number managementFeeDue; +}; + +LoanState +calculateRawLoanState( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t const paymentRemaining, + TenthBips16 const managementFeeRate); + +LoanState +calculateRawLoanState( + Number const& periodicPayment, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t const paymentRemaining, + TenthBips16 const managementFeeRate); + +LoanState +calculateRoundedLoanState( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding); + +LoanState +calculateRoundedLoanState(SLE::const_ref loan); + template Number -roundPeriodicPayment( +computeFee( A const& asset, - Number const& periodicPayment, + Number const& value, + TenthBips16 managementFeeRate, std::int32_t scale) { - return roundToAsset(asset, periodicPayment, scale, Number::upward); + return roundToAsset( + asset, + tenthBipsOfValue(value, managementFeeRate), + scale, + Number::downward); } +Number +calculateFullPaymentInterest( + Number const& rawPrincipalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t paymentInterval, + std::uint32_t prevPaymentDate, + std::uint32_t startDate, + TenthBips32 closeInterestRate); + +Number +calculateFullPaymentInterest( + 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 functions should rarely be used directly. More often, the ultimate // result needs to be roundToAsset'd. @@ -127,200 +239,256 @@ loanAccruedInterest( std::uint32_t prevPaymentDate, std::uint32_t paymentInterval); +#if LOANCOMPLETE inline Number -minusFee(Number const& value, TenthBips32 managementFeeRate) +minusFee(Number const& value, TenthBips16 managementFeeRate) { return tenthBipsOfValue(value, tenthBipsPerUnity - managementFeeRate); } +#endif template -PaymentComponents -computePaymentComponents( +Number +computeRoundedPrincipalComponent( A const& asset, - std::int32_t scale, - Number const& totalValueOutstanding, Number const& principalOutstanding, - Number const& periodicPayment, - Number const& periodicRate, - std::uint32_t paymentRemaining) + Number const& rawPrincipalOutstanding, + Number const& rawPrincipal, + Number const& roundedPeriodicPayment, + std::int32_t scale) { - /* - * 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::computePaymentComponents", - "Outstanding values are rounded"); - auto const roundedPeriodicPayment = - roundPeriodicPayment(asset, periodicPayment, scale); - if (paymentRemaining == 1 || - totalValueOutstanding <= roundedPeriodicPayment) - { - // If there's only one payment left, we need to pay off each of the loan - // parts. It's probably impossible for the subtraction to result in a - // negative value, but don't leave anything to chance. - Number interest = - std::max(Number{}, totalValueOutstanding - principalOutstanding); + // Adjust the principal payment by the rounding error between the true + // and rounded principal outstanding + auto const diff = roundToAsset( + asset, + principalOutstanding - rawPrincipalOutstanding, + scale, + Number::downward); - // Pay everything off - return { - .rawInterest = interest, - .rawPrincipal = principalOutstanding, - .roundedInterest = interest, - .roundedPrincipal = principalOutstanding, - .roundedPayment = interest + principalOutstanding, - .final = true}; + // If the rounded principal outstanding is greater than the true + // principal outstanding, we need to pay more principal to reduce + // the rounded principal outstanding + // + // If the rounded principal outstanding is less than the true + // principal outstanding, we need to pay less principal to allow the + // rounded principal outstanding to catch up + + auto const p = + roundToAsset(asset, rawPrincipal + diff, scale, Number::downward); + + // For particular loans, it's entirely possible for many of the first + // rounded payments to be all principal. + XRPL_ASSERT_PARTS( + p >= 0, + "rippled::detail::computeRoundedPrincipalComponent", + "principal part not negative"); + XRPL_ASSERT_PARTS( + p <= principalOutstanding, + "rippled::detail::computeRoundedPrincipalComponent", + "principal part not larger than outstanding principal"); + XRPL_ASSERT_PARTS( + !asset.integral() || abs(p - rawPrincipal) <= 1, + "rippled::detail::computeRoundedPrincipalComponent", + "principal part not larger than outstanding principal"); + XRPL_ASSERT_PARTS( + p <= roundedPeriodicPayment, + "rippled::detail::computeRoundedPrincipalComponent", + "principal part not larger than total payment"); + + // The asserts will be skipped in release builds, so check here to make + // sure nothing goes negative + if (p > roundedPeriodicPayment || p > principalOutstanding) + return std::min(roundedPeriodicPayment, principalOutstanding); + else if (p < 0) + return Number{}; + + return p; +} + +/** Returns the interest component of a payment WITHOUT accounting for + ** management fees + * + * In other words, it returns the combined value of the interest part that will + * go to the Vault and the management fee that will go to the Broker. + */ +template +Number +computeRoundedInterestComponent( + A const& asset, + Number const& interestOutstanding, + Number const& roundedPrincipal, + Number const& rawInterestOutstanding, + Number const& roundedPeriodicPayment, + std::int32_t scale) +{ + // Start by just using the non-principal part of the payment for interest + Number roundedInterest = roundedPeriodicPayment - roundedPrincipal; + XRPL_ASSERT( + isRounded(asset, roundedInterest, scale), + "ripple::detail::computeRoundedInterestComponent", + "initial interest computation is rounded"); + + { + // Adjust the interest payment by the rounding error between the true + // and rounded interest outstanding + // + // If the rounded interest outstanding is greater than the true interest + // outstanding, we need to pay more interest to reduce the rounded + // interest outstanding + // + // If the rounded interest outstanding is less than the true interest + // outstanding, we need to pay less interest to allow the rounded + // interest outstanding to catch up + auto const diff = roundToAsset( + asset, + interestOutstanding - rawInterestOutstanding, + scale, + Number::downward); + if (diff < beast::zero) + roundedInterest += diff; } - Number const rawValueOutstanding = periodicPayment * paymentRemaining; - Number const rawPrincipalOutstanding = loanPrincipalFromPeriodicPayment( - periodicPayment, periodicRate, paymentRemaining); - Number const rawInterestOutstanding = - rawValueOutstanding - rawPrincipalOutstanding; + // However, we cannot allow negative interest payments, therefore we need to + // cap the interest payment at 0. + // + // Ensure interest payment is non-negative and does not exceed the remaining + // payment after principal + return std::max(Number{}, roundedInterest); +} - /* - * From the spec, once the periodicPayment is computed: - * - * The principal and interest portions can be derived as follows: - * interest = principalOutstanding * periodicRate - * principal = periodicPayment - interest - */ - Number const rawInterest = rawPrincipalOutstanding * periodicRate; - Number const rawPrincipal = periodicPayment - rawInterest; - XRPL_ASSERT_PARTS( - rawInterest >= 0, - "ripple::detail::computePaymentComponents", - "valid raw interest"); - XRPL_ASSERT_PARTS( - rawPrincipal >= 0 && rawPrincipal <= rawPrincipalOutstanding, - "ripple::detail::computePaymentComponents", - "valid raw principal"); +// The Interest and Fee components need to be calculated together, because they +// can affect each other during computation in both directions. +template +std::pair +computeRoundedInterestAndFeeComponents( + A const& asset, + Number const& interestOutstanding, + Number const& managementFeeOutstanding, + Number const& roundedPrincipal, + Number const& rawInterestOutstanding, + Number const& rawManagementFeeOutstanding, + Number const& roundedPeriodicPayment, + Number const& periodicRate, + TenthBips16 managementFeeRate, + std::int32_t scale) +{ + // Zero interest means ZERO interest + if (periodicRate == 0) + return std::make_pair(Number{}, Number{}); - /* - Critical Calculation: Balancing Principal and Interest Outstanding + Number roundedInterest = computeRoundedInterestComponent( + asset, + interestOutstanding, + roundedPrincipal, + rawInterestOutstanding, + roundedPeriodicPayment, + scale); - This calculation maintains a delicate balance between keeping - principal outstanding and interest outstanding as close as possible to - reference values. However, we cannot perfectly match the reference - values due to rounding issues. + Number roundedFee = + computeFee(asset, roundedInterest, managementFeeRate, scale); - Key considerations: - 1. Since the periodic payment is rounded up, we have excess funds - that can be used to pay down the loan faster than the reference - calculation. - - 2. We must ensure that loan repayment is not too fast, otherwise we - will end up with negative principal outstanding or negative - interest outstanding. - - 3. We cannot allow the borrower to repay interest ahead of schedule. - If the borrower makes an overpayment, the interest portion could - go negative, requiring complex recalculation to refund the borrower by - reflecting the overpayment in the principal portion of the loan. - */ - - Number const roundedPrincipal = [&]() { - auto const p = roundToAsset( + { + // Adjust the interest fee by the rounding error between the true and + // rounded interest fee outstanding + auto const diff = roundToAsset( asset, - // Compute the delta that will get the tracked principalOutstanding - // amount as close to the true principal amount after the payment as - // possible. - principalOutstanding - (rawPrincipalOutstanding - rawPrincipal), + managementFeeOutstanding - rawManagementFeeOutstanding, scale, Number::downward); - // The principal part can only be 0 during intial loan validation. If it - // is 0, the Loan will not be created, but we don't want an assert - // aborting the process before we get that far. - XRPL_ASSERT_PARTS( - p >= 0, - "rippled::detail::computePaymentComponents", - "principal part not negative"); - XRPL_ASSERT_PARTS( - p <= principalOutstanding, - "rippled::detail::computePaymentComponents", - "principal part not larger than outstanding principal"); - XRPL_ASSERT_PARTS( - p <= roundedPeriodicPayment, - "rippled::detail::computePaymentComponents", - "principal part not larger than total payment"); + roundedFee += diff; - // Make sure nothing goes negative - if (p > roundedPeriodicPayment || p > principalOutstanding) - return std::min(roundedPeriodicPayment, principalOutstanding); - else if (p < 0) - return Number{}; + // But again, we cannot allow negative interest fees, therefore we need + // to cap the interest fee at 0 + roundedFee = std::max(Number{}, roundedFee); - return p; - }(); + // Finally, the rounded interest fee cannot exceed the outstanding + // interest fee + roundedFee = std::min(roundedFee, managementFeeOutstanding); + } - Number const roundedInterest = [&]() { - // Zero interest means ZERO interest - if (periodicRate == 0) - return Number{}; + // Remove the fee portion from the interest payment, as the fee is paid + // separately - // Compute the rounded interest outstanding - auto const interestOutstanding = - totalValueOutstanding - principalOutstanding; - // Compute the delta that will simply treat the rest of the rounded - // fixed payment amount as interest. - auto const iDiff = roundedPeriodicPayment - roundedPrincipal; + // Ensure that the interest payment does not become negative, this may + // happen with high interest fees + roundedInterest = std::max(Number{}, roundedInterest - roundedFee); - // Compute the delta that will get the untracked interestOutstanding - // amount as close as possible to the true interest amount after the - // payment as possible. - auto const iSync = interestOutstanding - - (roundToAsset(asset, rawInterestOutstanding, scale) - - roundToAsset(asset, rawInterest, scale)); - XRPL_ASSERT_PARTS( - isRounded(asset, iSync, scale), - "ripple::detail::computePaymentComponents", - "iSync is rounded"); + // Finally, ensure that the interest payment does not exceed the + // interest outstanding + roundedInterest = std::min(interestOutstanding, roundedInterest); - // Use the smaller of the two to ensure we don't overpay interest. - auto const i = std::min({iSync, iDiff, interestOutstanding}); - - // No negative interest! - if (i < 0) - return Number{}; - return i; - }(); + // Make sure the parts don't add up to too much + Number excess = roundedPeriodicPayment - roundedPrincipal - + roundedInterest - roundedFee; XRPL_ASSERT_PARTS( - roundedInterest >= 0 && isRounded(asset, roundedInterest, scale), - "ripple::detail::computePaymentComponents", - "valid rounded interest"); - XRPL_ASSERT_PARTS( - roundedPrincipal >= 0 && roundedPrincipal <= principalOutstanding && - roundedPrincipal <= roundedPeriodicPayment && - isRounded(asset, roundedPrincipal, scale), - "ripple::detail::computePaymentComponents", - "valid rounded principal"); - XRPL_ASSERT_PARTS( - roundedPrincipal + roundedInterest <= roundedPeriodicPayment, - "ripple::detail::computePaymentComponents", - "payment parts fit within payment limit"); + isRounded(asset, excess, scale), + "ripple::detail::computeRoundedInterestAndFeeComponents", + "excess is rounded"); - return { - .rawInterest = rawInterest, - .rawPrincipal = rawPrincipal, - .roundedInterest = roundedInterest, - .roundedPrincipal = roundedPrincipal, - .roundedPayment = roundedPeriodicPayment}; + if (excess < beast::zero) + { + // Take as much of the excess as we can out of the interest + auto part = std::min(roundedInterest, abs(excess)); + roundedInterest -= part; + excess += part; + + XRPL_ASSERT_PARTS( + excess <= beast::zero, + "ripple::detail::computeRoundedInterestAndFeeComponents", + "excess not positive (interest)"); + } + if (excess < beast::zero) + { + // If there's any left, take as much of the excess as we can out of the + // fee + auto part = std::min(roundedFee, abs(excess)); + roundedFee -= part; + excess += part; + } + + // The excess should never be negative, which indicates that the parts are + // trying to take more than the whole payment. The excess can be positive, + // which indicates that we're not going to take the whole payment amount, + // but if so, it must be small. + XRPL_ASSERT_PARTS( + excess == beast::zero || + (excess > beast::zero && + ((asset.integral() && excess < 3) || + (roundedPeriodicPayment.exponent() - excess.exponent() > 6))), + "ripple::detail::computeRoundedInterestAndFeeComponents", + "excess is zero (fee)"); + + XRPL_ASSERT_PARTS( + roundedFee >= beast::zero, + "ripple::detail::computeRoundedInterestAndFeeComponents", + "non-negative fee"); + XRPL_ASSERT_PARTS( + roundedInterest >= beast::zero, + "ripple::detail::computeRoundedInterestAndFeeComponents", + "non-negative interest"); + + return std::make_pair( + std::max(Number{}, roundedInterest), std::max(Number{}, roundedFee)); } struct PaymentComponentsPlus : public PaymentComponents { - Number fee{0}; + Number extraFee{0}; Number valueChange{0}; + Number totalDue; PaymentComponentsPlus( PaymentComponents const& p, Number f, Number v = Number{}) - : PaymentComponents(p), fee(f), valueChange(v) + : PaymentComponents(p) + , extraFee(f) + , valueChange(v) + , totalDue( + roundedPrincipal + roundedInterest + roundedManagementFee + + extraFee) { } }; @@ -331,6 +499,7 @@ doPayment( PaymentComponentsPlus const& payment, NumberProxy& totalValueOutstandingProxy, NumberProxy& principalOutstandingProxy, + NumberProxy& managementFeeOutstandingProxy, UInt32Proxy& paymentRemainingProxy, UInt32Proxy& prevPaymentDateProxy, UInt32OptionalProxy& nextDueDateProxy, @@ -341,7 +510,8 @@ doPayment( "ripple::detail::doPayment", "Next due date proxy set"); auto const totalValueDelta = payment.roundedPrincipal + - payment.roundedInterest - payment.valueChange; + payment.roundedInterest + payment.roundedManagementFee - + payment.valueChange; if (!payment.extra) { if (payment.final) @@ -367,11 +537,11 @@ doPayment( XRPL_ASSERT_PARTS( principalOutstandingProxy > payment.roundedPrincipal, "ripple::detail::doPayment", - "Full principal payment"); + "Partial principal payment"); XRPL_ASSERT_PARTS( totalValueOutstandingProxy > totalValueDelta, "ripple::detail::doPayment", - "Full value payment"); + "Partial value payment"); paymentRemainingProxy -= 1; @@ -380,10 +550,17 @@ doPayment( // old-fashioned way. nextDueDateProxy = *nextDueDateProxy + paymentInterval; } + // Management fees are expected to be relatively small, and could get to + // zero before the loan is paid off + XRPL_ASSERT_PARTS( + managementFeeOutstandingProxy >= payment.roundedManagementFee, + "ripple::detail::doPayment", + "Valid management fee"); } principalOutstandingProxy -= payment.roundedPrincipal; totalValueOutstandingProxy -= totalValueDelta; + managementFeeOutstandingProxy -= payment.roundedManagementFee; XRPL_ASSERT_PARTS( // Use an explicit cast because the template parameter can be @@ -392,138 +569,239 @@ doPayment( static_cast(totalValueOutstandingProxy), "ripple::detail::doPayment", "principal does not exceed total"); + XRPL_ASSERT_PARTS( + // Use an explicit cast because the template parameter can be + // ValueProxy or Number + static_cast(managementFeeOutstandingProxy) >= beast::zero, + "ripple::detail::doPayment", + "fee outstanding stays valid"); return LoanPaymentParts{ .principalPaid = payment.roundedPrincipal, .interestPaid = payment.roundedInterest, .valueChange = payment.valueChange, - .feeToPay = payment.fee}; + .managementFeePaid = payment.roundedManagementFee, + .extraFeePaid = payment.extraFee}; } -template < - AssetType A, - class NumberProxy, - class UInt32Proxy, - class UInt32OptionalProxy> +// This function mainly exists to guarantee isolation of the "sandbox" +// variables from the real / proxy variables that will affect actual +// ledger data in the caller. +template Expected -doOverpayment( +tryOverpayment( A const& asset, - ApplyView& view, PaymentComponentsPlus const& overpaymentComponents, - NumberProxy& totalValueOutstandingProxy, - NumberProxy& principalOutstandingProxy, - NumberProxy& periodicPaymentProxy, - TenthBips32 const interestRate, - std::uint32_t const paymentInterval, - UInt32Proxy& paymentRemainingProxy, - UInt32Proxy& prevPaymentDateProxy, - UInt32OptionalProxy& nextDueDateProxy, - TenthBips16 managementFeeRate, + Number& totalValueOutstanding, + Number& principalOutstanding, + Number& managementFeeOutstanding, + Number& periodicPayment, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t paymentRemaining, + std::uint32_t prevPaymentDate, + std::optional nextDueDate, + TenthBips16 const managementFeeRate, beast::Journal j) { - Number const totalInterestOutstandingBefore = - totalValueOutstandingProxy - principalOutstandingProxy; - // Compute what the properties would be if the loan was new in its current // state. They are not likely to match the original properties. We're // interested in the error. - auto const oldLoanProperties = computeLoanProperties( + auto const loanPropertiesBefore = computeLoanProperties( asset, - principalOutstandingProxy, + principalOutstanding, interestRate, paymentInterval, - paymentRemainingProxy, + paymentRemaining, managementFeeRate); - auto const accumulatedError = - oldLoanProperties.totalValueOutstanding - totalValueOutstandingProxy; + auto const accumulatedTotalValueError = + loanPropertiesBefore.totalValueOutstanding - totalValueOutstanding; + auto const accumulatedFeeError = + loanPropertiesBefore.managementFeeOwedToBroker - + managementFeeOutstanding; + auto const paymentParts = detail::doPayment( + overpaymentComponents, + totalValueOutstanding, + principalOutstanding, + managementFeeOutstanding, + paymentRemaining, + prevPaymentDate, + nextDueDate, + paymentInterval); + + auto newLoanProperties = computeLoanProperties( + asset, + principalOutstanding, + interestRate, + paymentInterval, + paymentRemaining, + managementFeeRate); + + newLoanProperties.totalValueOutstanding += accumulatedTotalValueError; + newLoanProperties.managementFeeOwedToBroker += accumulatedFeeError; + + if (newLoanProperties.firstPaymentPrincipal <= 0 && + principalOutstanding > 0) { - // Use temp variables to do the payment, so they can be thrown away if - // they don't work - Number totalValueOutstanding = totalValueOutstandingProxy; - Number principalOutstanding = principalOutstandingProxy; - std::uint32_t paymentRemaining = paymentRemainingProxy; - std::uint32_t prevPaymentDate = prevPaymentDateProxy; - std::optional nextDueDate = nextDueDateProxy; - - auto const paymentParts = detail::doPayment( - overpaymentComponents, - totalValueOutstanding, - principalOutstanding, - paymentRemaining, - prevPaymentDate, - nextDueDate, - paymentInterval); - - auto newLoanProperties = computeLoanProperties( - asset, - principalOutstanding, - interestRate, - paymentInterval, - paymentRemaining, - managementFeeRate); - - newLoanProperties.totalValueOutstanding += accumulatedError; - - if (newLoanProperties.firstPaymentPrincipal <= 0 && - principalOutstanding > 0) - { - // The overpayment has caused the loan to be in a state - // where no further principal can be paid. - JLOG(j.warn()) - << "Loan overpayment would cause loan to be stuck. " - "Rejecting overpayment, but normal payments are unaffected."; - return Unexpected(tesSUCCESS); - } - // Check that the other computed values are valid - if (newLoanProperties.interestOwedToVault < 0 || - newLoanProperties.totalValueOutstanding <= 0 || - newLoanProperties.periodicPayment <= 0) - { - // LCOV_EXCL_START - JLOG(j.warn()) << "Computed loan properties are invalid. Does " - "not compute. TotalValueOutstanding: " - << newLoanProperties.totalValueOutstanding - << ", PeriodicPayment: " - << newLoanProperties.periodicPayment - << ", InterestOwedToVault: " - << newLoanProperties.interestOwedToVault; - return Unexpected(tesSUCCESS); - // LCOV_EXCL_STOP - } - - totalValueOutstandingProxy = - newLoanProperties.totalValueOutstanding + accumulatedError; - principalOutstandingProxy = principalOutstanding; - periodicPaymentProxy = newLoanProperties.periodicPayment; - - XRPL_ASSERT_PARTS( - paymentRemainingProxy == paymentRemaining, - "ripple::detail::doOverpayment", - "paymentRemaining is unchanged"); - paymentRemainingProxy = paymentRemaining; - XRPL_ASSERT_PARTS( - prevPaymentDateProxy == prevPaymentDate, - "ripple::detail::doOverpayment", - "prevPaymentDate is unchanged"); - prevPaymentDateProxy = prevPaymentDate; - XRPL_ASSERT_PARTS( - nextDueDateProxy == nextDueDate, - "ripple::detail::doOverpayment", - "nextDueDate is unchanged"); - nextDueDateProxy = nextDueDate; - - /* - auto const totalInterestOutstandingAfter = - totalValueOutstanding - principalOutstanding; - */ - - return paymentParts; + // The overpayment has caused the loan to be in a state + // where no further principal can be paid. + JLOG(j.warn()) + << "Loan overpayment would cause loan to be stuck. " + "Rejecting overpayment, but normal payments are unaffected."; + return Unexpected(tesSUCCESS); } + + // Check that the other computed values are valid + if (newLoanProperties.periodicPayment <= 0 || + newLoanProperties.totalValueOutstanding <= 0 || + newLoanProperties.managementFeeOwedToBroker <= 0) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Overpayment not allowed: Computed loan " + "properties are invalid. Does " + "not compute. TotalValueOutstanding: " + << newLoanProperties.totalValueOutstanding + << ", PeriodicPayment : " + << newLoanProperties.periodicPayment + << ", ManagementFeeOwedToBroker: " + << newLoanProperties.managementFeeOwedToBroker; + return Unexpected(tesSUCCESS); + // LCOV_EXCL_STOP + } + + totalValueOutstanding = newLoanProperties.totalValueOutstanding; + periodicPayment = newLoanProperties.periodicPayment; + + return paymentParts; } -/* Handle possible late payments. +template +Expected +computeOverpayment( + A const& asset, + PaymentComponentsPlus const& overpaymentComponents, + NumberProxy& totalValueOutstandingProxy, + NumberProxy& principalOutstandingProxy, + NumberProxy& managementFeeOutstandingProxy, + NumberProxy& periodicPaymentProxy, + TenthBips32 const interestRate, + std::uint32_t const paymentInterval, + std::uint32_t const paymentRemaining, + std::uint32_t const prevPaymentDate, + std::optional const nextDueDate, + TenthBips16 const managementFeeRate, + beast::Journal j) +{ + // Use temp variables to do the payment, so they can be thrown away if + // they don't work + Number totalValueOutstanding = totalValueOutstandingProxy; + Number principalOutstanding = principalOutstandingProxy; + Number managementFeeOutstanding = managementFeeOutstandingProxy; + Number periodicPayment = periodicPaymentProxy; + + auto const ret = tryOverpayment( + asset, + overpaymentComponents, + totalValueOutstanding, + principalOutstanding, + managementFeeOutstanding, + periodicPayment, + interestRate, + paymentInterval, + paymentRemaining, + prevPaymentDate, + nextDueDate, + managementFeeRate, + j); + if (!ret) + return Unexpected(ret.error()); + + auto const& loanPaymentParts = *ret; + + if (principalOutstandingProxy <= principalOutstanding) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Overpayment not allowed: principal " + << "outstanding did not decrease. Before: " + << *principalOutstandingProxy + << ". After: " << principalOutstanding; + return Unexpected(tesSUCCESS); + // LCOV_EXCL_STOP + } + + XRPL_ASSERT_PARTS( + principalOutstandingProxy - principalOutstanding == + overpaymentComponents.roundedPrincipal, + "ripple::detail::computeOverpayment", + "principal change agrees"); + + XRPL_ASSERT_PARTS( + managementFeeOutstandingProxy - managementFeeOutstanding == + overpaymentComponents.roundedManagementFee && + overpaymentComponents.roundedManagementFee == beast::zero, + "ripple::detail::computeOverpayment", + "no fee change"); + + XRPL_ASSERT_PARTS( + totalValueOutstandingProxy - totalValueOutstanding - + overpaymentComponents.roundedPrincipal == + overpaymentComponents.valueChange, + "ripple::detail::computeOverpayment", + "value change agrees"); + + XRPL_ASSERT_PARTS( + loanPaymentParts.principalPaid == + overpaymentComponents.roundedPrincipal, + "ripple::detail::computeOverpayment", + "principal payment matches"); + XRPL_ASSERT_PARTS( + loanPaymentParts.interestPaid == overpaymentComponents.roundedInterest, + "ripple::detail::computeOverpayment", + "interest payment matches"); + XRPL_ASSERT_PARTS( + loanPaymentParts.valueChange == overpaymentComponents.valueChange, + "ripple::detail::computeOverpayment", + "value change matches"); + XRPL_ASSERT_PARTS( + loanPaymentParts.extraFeePaid == overpaymentComponents.extraFee, + "ripple::detail::computeOverpayment", + "extra fee payment matches"); + XRPL_ASSERT_PARTS( + loanPaymentParts.managementFeePaid == + overpaymentComponents.roundedManagementFee, + "ripple::detail::computeOverpayment", + "management fee payment matches"); + + // Update the loan object (via proxies) + totalValueOutstandingProxy = totalValueOutstanding; + principalOutstandingProxy = principalOutstanding; + managementFeeOutstandingProxy = managementFeeOutstanding; + periodicPaymentProxy = periodicPayment; + + return loanPaymentParts; +} + +std::pair +computeInterestAndFeeParts( + Number const& interest, + TenthBips16 managementFeeRate); + +template +std::pair +computeInterestAndFeeParts( + A const& asset, + Number const& interest, + TenthBips16 managementFeeRate, + std::int32_t loanScale) +{ + auto const fee = computeFee(asset, interest, managementFeeRate, loanScale); + + return std::make_pair(interest - fee, fee); +} + +/** Handle possible late payments. * * If this function processed a late payment, the return value will be * a LoanPaymentParts object. If the loan is not late, the return will be an @@ -538,7 +816,7 @@ doOverpayment( */ template Expected -handleLatePayment( +computeLatePayment( A const& asset, ApplyView const& view, Number const& principalOutstanding, @@ -548,47 +826,56 @@ handleLatePayment( std::int32_t loanScale, Number const& latePaymentFee, STAmount const& amount, + TenthBips16 managementFeeRate, beast::Journal j) { if (!hasExpired(view, nextDueDate)) return Unexpected(tesSUCCESS); // the payment is late - // Late payment interest is only the part of the interest that comes from - // being late, as computed by 3.2.4.1.2. + // Late payment interest is only the part of the interest that comes + // from being late, as computed by 3.2.4.1.2. auto const latePaymentInterest = loanLatePaymentInterest( - asset, principalOutstanding, lateInterestRate, view.parentCloseTime(), - nextDueDate, - loanScale); + nextDueDate); + + auto const [rawLateInterest, rawLateManagementFee] = + computeInterestAndFeeParts(latePaymentInterest, managementFeeRate); + auto const [roundedLateInterest, roundedLateManagementFee] = [&]() { + auto const interest = + roundToAsset(asset, latePaymentInterest, loanScale); + return computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + }(); + XRPL_ASSERT( - latePaymentInterest >= 0, - "ripple::detail::handleLatePayment : valid late interest"); + roundedLateInterest >= 0, + "ripple::detail::computeLatePayment : valid late interest"); PaymentComponentsPlus const late{ PaymentComponents{ - .rawInterest = periodic.rawInterest + latePaymentInterest, + .rawInterest = periodic.rawInterest + rawLateInterest, .rawPrincipal = periodic.rawPrincipal, - .roundedInterest = periodic.roundedInterest + latePaymentInterest, + .rawManagementFee = periodic.rawManagementFee, + .roundedInterest = periodic.roundedInterest + roundedLateInterest, .roundedPrincipal = periodic.roundedPrincipal, - .roundedPayment = periodic.roundedPayment}, - // A late payment pays both the normal fee, and the extra fee - periodic.fee + latePaymentFee, + .roundedManagementFee = periodic.roundedManagementFee}, + // A late payment pays both the normal fee, and the extra fees + periodic.extraFee + latePaymentFee + roundedLateManagementFee, // A late payment increases the value of the loan by the difference // between periodic and late payment interest - latePaymentInterest}; - auto const totalDue = - late.roundedPrincipal + late.roundedInterest + late.fee; + roundedLateInterest}; + XRPL_ASSERT_PARTS( - isRounded(asset, totalDue, loanScale), - "ripple::detail::handleLatePayment", + isRounded(asset, late.totalDue, loanScale), + "ripple::detail::computeLatePayment", "total due is rounded"); - if (amount < totalDue) + if (amount < late.totalDue) { JLOG(j.warn()) << "Late loan payment amount is insufficient. Due: " - << totalDue << ", paid: " << amount; + << late.totalDue << ", paid: " << amount; return Unexpected(tecINSUFFICIENT_PAYMENT); } @@ -604,10 +891,11 @@ handleLatePayment( */ template Expected -handleFullPayment( +computeFullPayment( A const& asset, ApplyView& view, Number const& principalOutstanding, + Number const& managementFeeOutstanding, Number const& periodicPayment, std::uint32_t paymentRemaining, std::uint32_t prevPaymentDate, @@ -619,6 +907,7 @@ handleFullPayment( Number const& periodicRate, Number const& closePaymentFee, STAmount const& amount, + TenthBips16 managementFeeRate, beast::Journal j) { if (paymentRemaining <= 1) @@ -628,44 +917,64 @@ handleFullPayment( Number const rawPrincipalOutstanding = loanPrincipalFromPeriodicPayment( periodicPayment, periodicRate, paymentRemaining); - auto const totalInterest = calculateFullPaymentInterest( - asset, + // Full payment interest consists of accrued normal interest and the + // prepayment penalty, as computed by 3.2.4.1.4. + auto const fullPaymentInterest = calculateFullPaymentInterest( rawPrincipalOutstanding, periodicRate, view.parentCloseTime(), paymentInterval, prevPaymentDate, startDate, - closeInterestRate, - loanScale); + closeInterestRate); - auto const closeFullPayment = - principalOutstanding + totalInterest + closePaymentFee; + auto const [rawFullInterest, rawFullManagementFee] = + computeInterestAndFeeParts(fullPaymentInterest, managementFeeRate); + auto const + [roundedFullInterest, roundedFullManagementFee, roundedFullExtraFee] = + [&]() { + auto const interest = + roundToAsset(asset, fullPaymentInterest, loanScale); + auto const parts = computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + // Apply as much of the fee to the outstanding fee, but no + // more + if (parts.second <= managementFeeOutstanding) + return std::make_tuple(parts.first, parts.second, Number{}); + return std::make_tuple( + parts.first, + managementFeeOutstanding, + parts.second - managementFeeOutstanding); + }(); - if (amount < closeFullPayment) + PaymentComponentsPlus const full{ + PaymentComponents{ + .rawInterest = rawFullInterest, + .rawPrincipal = rawPrincipalOutstanding, + .rawManagementFee = rawFullManagementFee, + .roundedInterest = roundedFullInterest, + .roundedPrincipal = principalOutstanding, + .roundedManagementFee = roundedFullManagementFee, + .final = true}, + // A full payment pays the single close payment fee, plus whatever part + // of the computed management fee is not outstanding in the Loan + closePaymentFee + roundedFullExtraFee, + // A full payment decreases the value of the loan by the + // difference between the interest paid and the expected + // outstanding interest return + roundedFullInterest - totalInterestOutstanding}; + + XRPL_ASSERT_PARTS( + isRounded(asset, full.totalDue, loanScale), + "ripple::detail::computeFullPayment", + "total due is rounded"); + + if (amount < full.totalDue) // If the payment is less than the full payment amount, it's not // sufficient to be a full payment, but that's not an error. return Unexpected(tesSUCCESS); - // Make a full payment - - PaymentComponentsPlus const result{ - PaymentComponents{ - .rawInterest = - principalOutstanding + totalInterest - rawPrincipalOutstanding, - .rawPrincipal = rawPrincipalOutstanding, - .roundedInterest = totalInterest, - .roundedPrincipal = principalOutstanding, - .roundedPayment = principalOutstanding + totalInterest, - .final = true}, - // A full payment only pays the single close payment fee - closePaymentFee, - // A full payment decreases the value of the loan by the - // difference between the interest paid and the expected - // outstanding interest return - totalInterest - totalInterestOutstanding}; - - return result; + return full; } } // namespace detail @@ -675,21 +984,162 @@ Number valueMinusFee( A const& asset, Number const& value, - TenthBips32 managementFeeRate, + TenthBips16 managementFeeRate, std::int32_t scale) { - return roundToAsset( - asset, detail::minusFee(value, managementFeeRate), scale); + return value - computeFee(asset, value, managementFeeRate, scale); } -struct LoanProperties +template +PaymentComponents +computePaymentComponents( + A const& asset, + std::int32_t scale, + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding, + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentRemaining, + TenthBips16 managementFeeRate) { - Number periodicPayment; - Number totalValueOutstanding; - Number interestOwedToVault; - std::int32_t loanScale; - Number firstPaymentPrincipal; -}; + /* + * 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, managementFeeOutstanding, scale), + "ripple::detail::computePaymentComponents", + "Outstanding values are rounded"); + auto const roundedPeriodicPayment = + roundPeriodicPayment(asset, periodicPayment, scale); + + LoanState const raw = calculateRawLoanState( + periodicPayment, periodicRate, paymentRemaining, managementFeeRate); + + if (paymentRemaining == 1 || + totalValueOutstanding <= roundedPeriodicPayment) + { + // If there's only one payment left, we need to pay off each of the loan + // parts. It's probably impossible for the subtraction to result in a + // negative value, but don't leave anything to chance. + Number interest = std::max( + Number{}, + totalValueOutstanding - principalOutstanding - + managementFeeOutstanding); + + // Pay everything off + return PaymentComponents{ + .rawInterest = raw.interestOutstanding, + .rawPrincipal = raw.principalOutstanding, + .rawManagementFee = raw.managementFeeDue, + .roundedInterest = interest, + .roundedPrincipal = principalOutstanding, + .roundedManagementFee = managementFeeOutstanding, + //.roundedPayment = totalValueOutstanding, + .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 + */ + Number const rawInterest = raw.principalOutstanding * periodicRate; + Number const rawPrincipal = periodicPayment - rawInterest; + Number const rawFee = tenthBipsOfValue(rawInterest, managementFeeRate); + XRPL_ASSERT_PARTS( + rawInterest >= 0, + "ripple::detail::computePaymentComponents", + "valid raw interest"); + XRPL_ASSERT_PARTS( + rawPrincipal >= 0 && rawPrincipal <= raw.principalOutstanding, + "ripple::detail::computePaymentComponents", + "valid raw principal"); + XRPL_ASSERT_PARTS( + rawFee >= 0 && rawFee <= raw.managementFeeDue, + "ripple::detail::computePaymentComponents", + "valid raw fee"); + + /* + Critical Calculation: Balancing Principal and Interest Outstanding + + This calculation maintains a delicate balance between keeping + principal outstanding and interest outstanding as close as possible to + reference values. However, we cannot perfectly match the reference + values due to rounding issues. + + Key considerations: + 1. Since the periodic payment is rounded up, we have excess funds + that can be used to pay down the loan faster than the reference + calculation. + + 2. We must ensure that loan repayment is not too fast, otherwise we + will end up with negative principal outstanding or negative + interest outstanding. + + 3. We cannot allow the borrower to repay interest ahead of schedule. + If the borrower makes an overpayment, the interest portion could + go negative, requiring complex recalculation to refund the borrower by + reflecting the overpayment in the principal portion of the loan. + */ + + Number const roundedPrincipal = detail::computeRoundedPrincipalComponent( + asset, + principalOutstanding, + raw.principalOutstanding, + rawPrincipal, + roundedPeriodicPayment, + scale); + + auto const [roundedInterest, roundedFee] = + detail::computeRoundedInterestAndFeeComponents( + asset, + totalValueOutstanding - principalOutstanding, + managementFeeOutstanding, + roundedPrincipal, + raw.interestOutstanding, + raw.managementFeeDue, + roundedPeriodicPayment, + periodicRate, + managementFeeRate, + scale); + + XRPL_ASSERT_PARTS( + roundedInterest >= 0 && isRounded(asset, roundedInterest, scale), + "ripple::detail::computePaymentComponents", + "valid rounded interest"); + XRPL_ASSERT_PARTS( + roundedFee >= 0 && roundedFee <= managementFeeOutstanding && + isRounded(asset, roundedFee, scale), + "ripple::detail::computePaymentComponents", + "valid rounded fee"); + XRPL_ASSERT_PARTS( + roundedPrincipal >= 0 && roundedPrincipal <= principalOutstanding && + roundedPrincipal <= roundedPeriodicPayment && + isRounded(asset, roundedPrincipal, scale), + "ripple::detail::computePaymentComponents", + "valid rounded principal"); + XRPL_ASSERT_PARTS( + roundedPrincipal + roundedInterest + roundedFee <= + roundedPeriodicPayment, + "ripple::detail::computePaymentComponents", + "payment parts fit within payment limit"); + + return PaymentComponents{ + .rawInterest = rawInterest - rawFee, + .rawPrincipal = rawPrincipal, + .rawManagementFee = rawFee, + .roundedInterest = roundedInterest, + .roundedPrincipal = roundedPrincipal, + .roundedManagementFee = roundedFee, + //.roundedPayment = roundedPeriodicPayment + }; +} template LoanProperties @@ -699,7 +1149,7 @@ computeLoanProperties( TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, - TenthBips32 managementFeeRate) + TenthBips16 managementFeeRate) { auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval); XRPL_ASSERT( @@ -733,24 +1183,7 @@ computeLoanProperties( principalOutstanding = roundToAsset( asset, principalOutstanding, loanScale, Number::to_nearest); - auto const firstPaymentPrincipal = [&]() { - // Compute the parts for the first payment. Ensure that the - // principal payment will actually change the principal. - auto const paymentComponents = detail::computePaymentComponents( - asset, - loanScale, - totalValueOutstanding, - principalOutstanding, - periodicPayment, - periodicRate, - paymentsRemaining); - - // The unrounded principal part needs to be large enough to affect the - // principal. What to do if not is left to the caller - return paymentComponents.rawPrincipal; - }(); - - auto const interestOwedToVault = valueMinusFee( + auto const feeOwedToBroker = computeFee( asset, /* * This formula is from the XLS-66 spec, section 3.2.4.2 (Total Loan @@ -760,84 +1193,33 @@ computeLoanProperties( managementFeeRate, loanScale); + auto const firstPaymentPrincipal = [&]() { + // Compute the parts for the first payment. Ensure that the + // principal payment will actually change the principal. + auto const paymentComponents = computePaymentComponents( + asset, + loanScale, + totalValueOutstanding, + principalOutstanding, + feeOwedToBroker, + periodicPayment, + periodicRate, + paymentsRemaining, + managementFeeRate); + + // The unrounded principal part needs to be large enough to affect the + // principal. What to do if not is left to the caller + return paymentComponents.rawPrincipal; + }(); + return LoanProperties{ .periodicPayment = periodicPayment, .totalValueOutstanding = totalValueOutstanding, - .interestOwedToVault = interestOwedToVault, + .managementFeeOwedToBroker = feeOwedToBroker, .loanScale = loanScale, .firstPaymentPrincipal = firstPaymentPrincipal}; } -template -Number -calculateFullPaymentInterest( - A const& asset, - Number const& rawPrincipalOutstanding, - Number const& periodicRate, - NetClock::time_point parentCloseTime, - std::uint32_t paymentInterval, - std::uint32_t prevPaymentDate, - std::uint32_t startDate, - TenthBips32 closeInterestRate, - std::int32_t loanScale) -{ - // If there is more than one payment remaining, see if enough was - // paid for a full payment - auto const accruedInterest = roundToAsset( - asset, - detail::loanAccruedInterest( - rawPrincipalOutstanding, - periodicRate, - parentCloseTime, - startDate, - prevPaymentDate, - paymentInterval), - loanScale); - XRPL_ASSERT( - accruedInterest >= 0, - "ripple::detail::handleFullPayment : valid accrued interest"); - - auto const prepaymentPenalty = roundToAsset( - asset, - tenthBipsOfValue(rawPrincipalOutstanding, closeInterestRate), - loanScale); - XRPL_ASSERT( - prepaymentPenalty >= 0, - "ripple::detail::handleFullPayment : valid prepayment " - "interest"); - return accruedInterest + prepaymentPenalty; -} - -template -Number -calculateFullPaymentInterest( - A const& asset, - 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, - std::int32_t loanScale) -{ - Number const rawPrincipalOutstanding = - detail::loanPrincipalFromPeriodicPayment( - periodicPayment, periodicRate, paymentRemaining); - - return calculateFullPaymentInterest( - asset, - rawPrincipalOutstanding, - periodicRate, - parentCloseTime, - paymentInterval, - prevPaymentDate, - startDate, - closeInterestRate, - loanScale); -} - #if LOANCOMPLETE template Number @@ -955,21 +1337,18 @@ loanTotalInterestOutstanding( paymentInterval, paymentsRemaining)); } -#endif - template Number loanInterestOutstandingMinusFee( A const& asset, Number const& totalInterestOutstanding, - TenthBips32 managementFeeRate, + TenthBips16 managementFeeRate, std::int32_t scale) { return valueMinusFee( asset, totalInterestOutstanding, managementFeeRate, scale); } -#if LOANCOMPLETE template Number loanInterestOutstandingMinusFee( @@ -979,7 +1358,7 @@ loanInterestOutstandingMinusFee( TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, - TenthBips32 managementFeeRate) + TenthBips16 managementFeeRate) { return loanInterestOutstandingMinusFee( asset, @@ -993,7 +1372,6 @@ loanInterestOutstandingMinusFee( managementFeeRate, scale); } -#endif template Number @@ -1014,6 +1392,7 @@ loanLatePaymentInterest( nextPaymentDueDate), scale); } +#endif template bool @@ -1029,8 +1408,8 @@ loanMakePayment( A const& asset, ApplyView& view, SLE::ref loan, + SLE::const_ref brokerSle, STAmount const& amount, - TenthBips16 managementFeeRate, beast::Journal j) { /* @@ -1047,6 +1426,9 @@ loanMakePayment( return Unexpected(tecKILLED); } + auto totalValueOutstandingProxy = loan->at(sfTotalValueOutstanding); + auto managementFeeOutstandingProxy = loan->at(sfManagementFeeOutstanding); + // Next payment due date must be set unless the loan is complete auto nextDueDateProxy = loan->at(~sfNextPaymentDueDate); if (!nextDueDateProxy) @@ -1056,7 +1438,6 @@ loanMakePayment( } std::int32_t const loanScale = loan->at(sfLoanScale); - auto totalValueOutstandingProxy = loan->at(sfTotalValueOutstanding); TenthBips32 const interestRate{loan->at(sfInterestRate)}; TenthBips32 const lateInterestRate{loan->at(sfLateInterestRate)}; @@ -1066,6 +1447,7 @@ loanMakePayment( Number const latePaymentFee = loan->at(sfLatePaymentFee); Number const closePaymentFee = roundToAsset(asset, loan->at(sfClosePaymentFee), loanScale); + TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; auto const periodicPayment = loan->at(sfPeriodicPayment); @@ -1087,23 +1469,25 @@ loanMakePayment( view.update(loan); detail::PaymentComponentsPlus const periodic{ - detail::computePaymentComponents( + computePaymentComponents( asset, loanScale, totalValueOutstandingProxy, principalOutstandingProxy, + managementFeeOutstandingProxy, periodicPayment, periodicRate, - paymentRemainingProxy), + paymentRemainingProxy, + managementFeeRate), serviceFee}; XRPL_ASSERT_PARTS( - periodic.roundedPrincipal > 0, + periodic.roundedPrincipal >= 0, "ripple::loanMakePayment", - "regular payment pays principal"); + "regular payment valid principal"); // ------------------------------------------------------------- // late payment handling - if (auto const latePaymentComponents = detail::handleLatePayment( + if (auto const latePaymentComponents = detail::computeLatePayment( asset, view, principalOutstandingProxy, @@ -1113,12 +1497,14 @@ loanMakePayment( loanScale, latePaymentFee, amount, + managementFeeRate, j)) { return doPayment( *latePaymentComponents, totalValueOutstandingProxy, principalOutstandingProxy, + managementFeeOutstandingProxy, paymentRemainingProxy, prevPaymentDateProxy, nextDueDateProxy, @@ -1132,13 +1518,16 @@ loanMakePayment( // ------------------------------------------------------------- // full payment handling - auto const totalInterestOutstanding = - totalValueOutstandingProxy - principalOutstandingProxy; + LoanState const roundedLoanState = calculateRoundedLoanState( + totalValueOutstandingProxy, + principalOutstandingProxy, + managementFeeOutstandingProxy); - if (auto const fullPaymentComponents = detail::handleFullPayment( + if (auto const fullPaymentComponents = detail::computeFullPayment( asset, view, principalOutstandingProxy, + managementFeeOutstandingProxy, periodicPayment, paymentRemainingProxy, prevPaymentDateProxy, @@ -1146,15 +1535,17 @@ loanMakePayment( paymentInterval, closeInterestRate, loanScale, - totalInterestOutstanding, + roundedLoanState.interestDue, periodicRate, closePaymentFee, amount, + managementFeeRate, j)) return doPayment( *fullPaymentComponents, totalValueOutstandingProxy, principalOutstandingProxy, + managementFeeOutstandingProxy, paymentRemainingProxy, prevPaymentDateProxy, nextDueDateProxy, @@ -1173,8 +1564,7 @@ loanMakePayment( // This will keep a running total of what is actually paid, if the payment // is sufficient for a single payment - Number totalPaid = - periodic.roundedInterest + periodic.roundedPrincipal + periodic.fee; + Number totalPaid = periodic.totalDue; if (amount < totalPaid) { @@ -1187,6 +1577,7 @@ loanMakePayment( periodic, totalValueOutstandingProxy, principalOutstandingProxy, + managementFeeOutstandingProxy, paymentRemainingProxy, prevPaymentDateProxy, nextDueDateProxy, @@ -1198,15 +1589,17 @@ loanMakePayment( { // Try to make more payments detail::PaymentComponentsPlus const nextPayment{ - detail::computePaymentComponents( + computePaymentComponents( asset, loanScale, totalValueOutstandingProxy, principalOutstandingProxy, + managementFeeOutstandingProxy, periodicPayment, periodicRate, - paymentRemainingProxy), - periodic.fee}; + paymentRemainingProxy, + managementFeeRate), + serviceFee}; XRPL_ASSERT_PARTS( nextPayment.roundedPrincipal > 0, "ripple::loanMakePayment", @@ -1218,19 +1611,16 @@ loanMakePayment( nextPayment.rawPrincipal >= periodic.rawPrincipal, "ripple::loanMakePayment : increasing principal"); - // the fee part doesn't change - auto const due = nextPayment.roundedInterest + - nextPayment.roundedPrincipal + periodic.fee; - - if (amount < totalPaid + due) + if (amount < totalPaid + nextPayment.totalDue) // We're done making payments. break; - totalPaid += due; + totalPaid += nextPayment.totalDue; totalParts += detail::doPayment( nextPayment, totalValueOutstandingProxy, principalOutstandingProxy, + managementFeeOutstandingProxy, paymentRemainingProxy, prevPaymentDateProxy, nextDueDateProxy, @@ -1243,7 +1633,7 @@ loanMakePayment( XRPL_ASSERT_PARTS( totalParts.principalPaid + totalParts.interestPaid + - totalParts.feeToPay == + totalParts.extraFeePaid + totalParts.managementFeePaid == totalPaid, "ripple::loanMakePayment", "payment parts add up"); @@ -1252,7 +1642,7 @@ loanMakePayment( "ripple::loanMakePayment", "no value change"); XRPL_ASSERT_PARTS( - totalParts.feeToPay == periodic.fee * numPayments, + totalParts.extraFeePaid == periodic.extraFee * numPayments, "ripple::loanMakePayment", "fee parts add up"); @@ -1279,33 +1669,47 @@ loanMakePayment( // TODO: Is the overpaymentInterestRate an APR or flat? - Number const interest = - tenthBipsOfValue(payment, overpaymentInterestRate); - Number const roundedInterest = roundToAsset(asset, interest, loanScale); + auto const [rawOverpaymentInterest, rawOverpaymentManagementFee] = + [&]() { + Number const interest = + tenthBipsOfValue(payment, overpaymentInterestRate); + return detail::computeInterestAndFeeParts( + interest, managementFeeRate); + }(); + auto const + [roundedOverpaymentInterest, roundedOverpaymentManagementFee] = + [&]() { + Number const interest = + roundToAsset(asset, rawOverpaymentInterest, loanScale); + return detail::computeInterestAndFeeParts( + asset, interest, managementFeeRate, loanScale); + }(); detail::PaymentComponentsPlus overpaymentComponents{ PaymentComponents{ - .rawInterest = interest, - .rawPrincipal = payment - interest, - .roundedInterest = roundedInterest, - .roundedPrincipal = payment - roundedInterest, - .roundedPayment = payment, + .rawInterest = rawOverpaymentInterest, + .rawPrincipal = payment - rawOverpaymentInterest, + .rawManagementFee = 0, + .roundedInterest = roundedOverpaymentInterest, + .roundedPrincipal = payment - roundedOverpaymentInterest, + .roundedManagementFee = 0, .extra = true}, fee, - roundedInterest}; + roundedOverpaymentInterest}; // Don't process an overpayment if the whole amount (or more!) // gets eaten by fees and interest. if (overpaymentComponents.rawPrincipal > 0 && overpaymentComponents.roundedPrincipal > 0) { + // Can't just use periodicPayment here, because it might change auto periodicPaymentProxy = loan->at(sfPeriodicPayment); - if (auto const overResult = detail::doOverpayment( + if (auto const overResult = detail::computeOverpayment( asset, - view, overpaymentComponents, totalValueOutstandingProxy, principalOutstandingProxy, + managementFeeOutstandingProxy, periodicPaymentProxy, interestRate, paymentInterval, @@ -1335,8 +1739,11 @@ loanMakePayment( isRounded(asset, totalParts.valueChange, loanScale), "ripple::loanMakePayment : loan value change rounded"); XRPL_ASSERT( - isRounded(asset, totalParts.feeToPay, loanScale), - "ripple::loanMakePayment : total fee to pay rounded"); + isRounded(asset, totalParts.extraFeePaid, loanScale), + "ripple::loanMakePayment : extra fee paid rounded"); + XRPL_ASSERT( + isRounded(asset, totalParts.managementFeePaid, loanScale), + "ripple::loanMakePayment : management fee paid rounded"); return totalParts; } diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index 447c940cc0..755c251d3a 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -44,6 +44,130 @@ loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) (365 * 24 * 60 * 60); } +Number +calculateFullPaymentInterest( + Number const& rawPrincipalOutstanding, + Number const& periodicRate, + NetClock::time_point parentCloseTime, + std::uint32_t paymentInterval, + std::uint32_t prevPaymentDate, + std::uint32_t startDate, + TenthBips32 closeInterestRate) +{ + // If there is more than one payment remaining, see if enough was + // paid for a full payment + auto const accruedInterest = detail::loanAccruedInterest( + rawPrincipalOutstanding, + periodicRate, + parentCloseTime, + startDate, + prevPaymentDate, + paymentInterval); + XRPL_ASSERT( + accruedInterest >= 0, + "ripple::detail::computeFullPaymentInterest : valid accrued interest"); + + auto const prepaymentPenalty = + tenthBipsOfValue(rawPrincipalOutstanding, closeInterestRate); + XRPL_ASSERT( + prepaymentPenalty >= 0, + "ripple::detail::computeFullPaymentInterest : valid prepayment " + "interest"); + + return accruedInterest + prepaymentPenalty; +} + +Number +calculateFullPaymentInterest( + 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 calculateFullPaymentInterest( + rawPrincipalOutstanding, + periodicRate, + parentCloseTime, + paymentInterval, + prevPaymentDate, + startDate, + closeInterestRate); +} + +LoanState +calculateRawLoanState( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t const paymentRemaining, + TenthBips16 const managementFeeRate) +{ + Number const rawValueOutstanding = periodicPayment * paymentRemaining; + Number const rawPrincipalOutstanding = + detail::loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); + Number const rawInterestOutstanding = + rawValueOutstanding - rawPrincipalOutstanding; + Number const rawManagementFeeOutstanding = + tenthBipsOfValue(rawInterestOutstanding, managementFeeRate); + + return LoanState{ + .valueOutstanding = rawValueOutstanding, + .principalOutstanding = rawPrincipalOutstanding, + .interestOutstanding = rawInterestOutstanding, + .interestDue = rawInterestOutstanding - rawManagementFeeOutstanding, + .managementFeeDue = rawManagementFeeOutstanding}; +}; + +LoanState +calculateRawLoanState( + Number const& periodicPayment, + TenthBips32 interestRate, + std::uint32_t paymentInterval, + std::uint32_t const paymentRemaining, + TenthBips16 const managementFeeRate) +{ + return calculateRawLoanState( + periodicPayment, + loanPeriodicRate(interestRate, paymentInterval), + paymentRemaining, + managementFeeRate); +} + +LoanState +calculateRoundedLoanState( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding) +{ + // This implementation is pretty trivial, but ensures the calculations are + // consistent everywhere, and reduces copy/paste errors. + Number const interestOutstanding = + totalValueOutstanding - principalOutstanding; + return { + .valueOutstanding = totalValueOutstanding, + .principalOutstanding = principalOutstanding, + .interestOutstanding = interestOutstanding, + .interestDue = interestOutstanding - managementFeeOutstanding, + .managementFeeDue = managementFeeOutstanding}; +} + +LoanState +calculateRoundedLoanState(SLE::const_ref loan) +{ + return calculateRoundedLoanState( + loan->at(sfTotalValueOutstanding), + loan->at(sfPrincipalOutstanding), + loan->at(sfManagementFeeOutstanding)); +} + namespace detail { Number @@ -128,6 +252,17 @@ loanPrincipalFromPeriodicPayment( computePaymentFactor(periodicRate, paymentsRemaining); } +std::pair +computeInterestAndFeeParts( + Number const& interest, + TenthBips16 managementFeeRate) +{ + auto const fee = tenthBipsOfValue(interest, managementFeeRate); + + // No error tracking needed here because this is extra + return std::make_pair(interest - fee, fee); +} + Number loanLatePaymentInterest( Number const& principalOutstanding, diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index 6137c96b06..17a1fc8013 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -2486,7 +2486,7 @@ ValidLoan::finalize( &sfClosePaymentFee, &sfPrincipalOutstanding, &sfTotalValueOutstanding, - &sfInterestOwed}) + &sfManagementFeeOutstanding}) { if (after->at(*field) < 0) { diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index df866b1237..f0a69820a6 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -133,6 +133,13 @@ LoanManage::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } +Number +owedToVault(SLE::ref loanSle) +{ + return loanSle->at(sfTotalValueOutstanding) - + loanSle->at(sfManagementFeeOutstanding); +} + TER LoanManage::defaultLoan( ApplyView& view, @@ -148,10 +155,13 @@ LoanManage::defaultLoan( auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); auto principalOutstandingProxy = loanSle->at(sfPrincipalOutstanding); - auto interestOwedProxy = loanSle->at(sfInterestOwed); + auto managementFeeOutstandingProxy = + loanSle->at(sfManagementFeeOutstanding); - Number const totalDefaultAmount = - principalOutstandingProxy + interestOwedProxy; + auto totalValueOutstandingProxy = loanSle->at(sfTotalValueOutstanding); + auto paymentRemainingProxy = loanSle->at(sfPaymentRemaining); + + Number const totalDefaultAmount = owedToVault(loanSle); // Apply the First-Loss Capital to the Default Amount TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; @@ -269,10 +279,10 @@ LoanManage::defaultLoan( // Update the Loan object: loanSle->setFlag(lsfLoanDefault); - loanSle->at(sfTotalValueOutstanding) = 0; - loanSle->at(sfPaymentRemaining) = 0; + totalValueOutstandingProxy = 0; + paymentRemainingProxy = 0; principalOutstandingProxy = 0; - interestOwedProxy = 0; + managementFeeOutstandingProxy = 0; loanSle->at(~sfNextPaymentDueDate) = std::nullopt; view.update(loanSle); @@ -294,8 +304,8 @@ LoanManage::impairLoan( SLE::ref vaultSle, beast::Journal j) { - Number const lossUnrealized = - loanSle->at(sfPrincipalOutstanding) + loanSle->at(sfInterestOwed); + Number const lossUnrealized = owedToVault(loanSle); + // Update the Vault object(set "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); vaultLossUnrealizedProxy += lossUnrealized; @@ -333,8 +343,7 @@ LoanManage::unimpairLoan( { // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - Number const lossReversed = - loanSle->at(sfPrincipalOutstanding) + loanSle->at(sfInterestOwed); + Number const lossReversed = owedToVault(loanSle); if (vaultLossUnrealizedProxy < lossReversed) { // LCOV_EXCL_START diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp index 0985b194cd..c25c8fffb8 100644 --- a/src/xrpld/app/tx/detail/LoanPay.cpp +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -91,7 +91,6 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) return normalCost; if (auto const fullInterest = calculateFullPaymentInterest( - asset, loanSle->at(sfPeriodicPayment), loanPeriodicRate( TenthBips32(loanSle->at(sfInterestRate)), @@ -101,8 +100,7 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) loanSle->at(sfPaymentInterval), loanSle->at(sfPreviousPaymentDate), loanSle->at(sfStartDate), - TenthBips32(loanSle->at(sfCloseInterestRate)), - scale); + TenthBips32(loanSle->at(sfCloseInterestRate))); amount > loanSle->at(sfPrincipalOutstanding) + fullInterest + loanSle->at(sfClosePaymentFee)) return normalCost; @@ -269,19 +267,8 @@ LoanPay::doApply() LoanManage::unimpairLoan(view, loanSle, vaultSle, j_); } - TenthBips16 managementFeeRate{brokerSle->at(sfManagementFeeRate)}; - auto const managementFeeOutstanding = [&]() { - auto const m = loanSle->at(sfTotalValueOutstanding) - - loanSle->at(sfPrincipalOutstanding) - loanSle->at(sfInterestOwed); - // It shouldn't be possible for this to result in a negative number, but - // with overpayments, who knows? - if (m < 0) - return Number{}; - return m; - }(); - Expected paymentParts = - loanMakePayment(asset, view, loanSle, amount, managementFeeRate, j_); + loanMakePayment(asset, view, loanSle, brokerSle, amount, j_); if (!paymentParts) { @@ -312,11 +299,16 @@ LoanPay::doApply() "ripple::LoanPay::doApply", "valid principal paid"); XRPL_ASSERT_PARTS( - paymentParts->feeToPay >= 0, + paymentParts->extraFeePaid >= 0, "ripple::LoanPay::doApply", "valid fee paid"); + XRPL_ASSERT_PARTS( + paymentParts->managementFeePaid >= 0, + "ripple::LoanPay::doApply", + "valid management fee paid"); + if (paymentParts->principalPaid < 0 || paymentParts->interestPaid < 0 || - paymentParts->feeToPay < 0) + paymentParts->extraFeePaid < 0 || paymentParts->managementFeePaid < 0) { // LCOV_EXCL_START JLOG(j_.fatal()) << "Loan payment computation returned invalid values."; @@ -328,54 +320,29 @@ LoanPay::doApply() // LoanBroker object state changes view.update(brokerSle); - auto interestOwedProxy = loanSle->at(sfInterestOwed); + auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable); + // 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 = assetsAvailableProxy->value().exponent(); - auto const [managementFee, interestPaidForDebt, interestPaidExtra] = [&]() { - auto const interestOwed = - paymentParts->interestPaid - paymentParts->valueChange; - auto const interestPaidExtra = paymentParts->valueChange; - - auto const managementFeeOwed = std::min( - managementFeeOutstanding, - roundToAsset( - asset, - tenthBipsOfValue(interestOwed, managementFeeRate), - loanScale)); - auto const managementFeeExtra = roundToAsset( - asset, - tenthBipsOfValue(interestPaidExtra, managementFeeRate), - loanScale); - auto const interestForDebt = interestOwed - managementFeeOwed; - auto const interestExtra = interestPaidExtra - managementFeeExtra; - auto const owed = *interestOwedProxy; - if (interestForDebt > owed) - return std::make_tuple( - interestOwed - owed + managementFeeExtra, owed, interestExtra); - return std::make_tuple( - managementFeeOwed + managementFeeExtra, - interestForDebt, - interestExtra); - }(); - XRPL_ASSERT_PARTS( - managementFee >= 0 && interestPaidForDebt >= 0 && - interestPaidExtra >= 0 && - (managementFee + interestPaidForDebt + interestPaidExtra == - paymentParts->interestPaid) && - isRounded(asset, managementFee, loanScale) && - isRounded(asset, interestPaidForDebt, loanScale) && - isRounded(asset, interestPaidExtra, loanScale), - "ripple::LoanPay::doApply", - "management fee computation is valid"); + auto const totalPaidToVaultRaw = + paymentParts->principalPaid + paymentParts->interestPaid; + auto const totalPaidToVaultRounded = + roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::downward); auto const totalPaidToVaultForDebt = - paymentParts->principalPaid + interestPaidForDebt; - auto const totalPaidToVault = totalPaidToVaultForDebt + interestPaidExtra; + totalPaidToVaultRaw - paymentParts->valueChange; - auto const totalPaidToBroker = paymentParts->feeToPay + managementFee; + auto const totalPaidToBroker = + paymentParts->managementFeePaid + paymentParts->extraFeePaid; + auto const totalPaid = totalPaidToVaultRaw + totalPaidToBroker; + auto const totalParts = paymentParts->principalPaid + + paymentParts->interestPaid + paymentParts->managementFeePaid + + paymentParts->extraFeePaid; XRPL_ASSERT_PARTS( - (totalPaidToVault + totalPaidToBroker) == + (totalPaidToVaultRaw + totalPaidToBroker) == (paymentParts->principalPaid + paymentParts->interestPaid + - paymentParts->feeToPay), + paymentParts->managementFeePaid + paymentParts->extraFeePaid), "ripple::LoanPay::doApply", "payments add up"); @@ -398,32 +365,23 @@ LoanPay::doApply() // Vault object state changes view.update(vaultSle); - // auto const available = *vaultSle->at(sfAssetsAvailable); - // auto const total = *vaultSle->at(sfAssetsTotal); - // auto const unavailable = total - available; + { + auto assetsTotalProxy = vaultSle->at(sfAssetsTotal); - vaultSle->at(sfAssetsAvailable) += totalPaidToVault; - vaultSle->at(sfAssetsTotal) += interestPaidExtra; - interestOwedProxy -= interestPaidForDebt; - XRPL_ASSERT_PARTS( - *vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal), - "ripple::LoanPay::doApply", - "assets available must not be greater than assets outstanding"); + assetsAvailableProxy += totalPaidToVaultRounded; + assetsTotalProxy += paymentParts->valueChange; - // auto const available = *vaultSle->at(sfAssetsAvailable); - // auto const total = *vaultSle->at(sfAssetsTotal); - // auto const unavailable = total - available; + XRPL_ASSERT_PARTS( + *assetsAvailableProxy <= *assetsTotalProxy, + "ripple::LoanPay::doApply", + "assets available must not be greater than assets outstanding"); + } // Move funds XRPL_ASSERT_PARTS( - totalPaidToVault + totalPaidToBroker <= amount, + totalPaidToVaultRounded + totalPaidToBroker <= amount, "ripple::LoanPay::doApply", "amount is sufficient"); - XRPL_ASSERT_PARTS( - totalPaidToVault + totalPaidToBroker <= paymentParts->principalPaid + - paymentParts->interestPaid + paymentParts->feeToPay, - "ripple::LoanPay::doApply", - "payment agreement"); if (!sendBrokerFeeToOwner) { @@ -452,7 +410,7 @@ LoanPay::doApply() view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); #endif - if (totalPaidToVault != beast::zero) + if (totalPaidToVaultRounded != beast::zero) { if (auto const ter = requireAuth( view, asset, vaultPseudoAccount, AuthType::StrongAuth)) @@ -484,7 +442,7 @@ LoanPay::doApply() view, account_, asset, - {{vaultPseudoAccount, totalPaidToVault}, + {{vaultPseudoAccount, totalPaidToVaultRounded}, {brokerPayee, totalPaidToBroker}}, j_, WaiveTransferFee::Yes)) diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index 5bd544bcf3..af18e12d27 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -341,7 +341,7 @@ LoanSet::doApply() interestRate, paymentInterval, paymentTotal, - TenthBips32{brokerSle->at(sfManagementFeeRate)}); + TenthBips16{brokerSle->at(sfManagementFeeRate)}); // Check that relevant values won't lose precision. This is mostly only // relevant for IOU assets. @@ -427,7 +427,7 @@ LoanSet::doApply() } // Check that the other computed values are valid - if (properties.interestOwedToVault < 0 || + if (properties.managementFeeOwedToBroker < 0 || properties.totalValueOutstanding <= 0 || properties.periodicPayment <= 0) { @@ -438,12 +438,16 @@ LoanSet::doApply() // LCOV_EXCL_STOP } + LoanState const state = calculateRoundedLoanState( + properties.totalValueOutstanding, + principalRequested, + properties.managementFeeOwedToBroker); + auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{}); auto const loanAssetsToBorrower = principalRequested - originationFee; - auto const newDebtDelta = - principalRequested + properties.interestOwedToVault; + auto const newDebtDelta = principalRequested + state.interestDue; auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); debtMaximum != 0 && debtMaximum < newDebtTotal) @@ -528,8 +532,7 @@ LoanSet::doApply() WaiveTransferFee::Yes)) return ter; - // The portion of the loan interest that will go to the vault (total - // interest minus the management fee) + // Get shortcuts to the loan property values auto const startDate = view.info().closeTime.time_since_epoch().count(); auto loanSequenceProxy = brokerSle->at(sfLoanSequence); @@ -569,7 +572,7 @@ LoanSet::doApply() loan->at(sfPrincipalOutstanding) = principalRequested; loan->at(sfPeriodicPayment) = properties.periodicPayment; loan->at(sfTotalValueOutstanding) = properties.totalValueOutstanding; - loan->at(sfInterestOwed) = properties.interestOwedToVault; + loan->at(sfManagementFeeOutstanding) = properties.managementFeeOwedToBroker; loan->at(sfPreviousPaymentDate) = 0; loan->at(sfNextPaymentDueDate) = startDate + paymentInterval; loan->at(sfPaymentRemaining) = paymentTotal; @@ -577,7 +580,7 @@ LoanSet::doApply() // Update the balances in the vault vaultSle->at(sfAssetsAvailable) -= principalRequested; - vaultSle->at(sfAssetsTotal) += properties.interestOwedToVault; + vaultSle->at(sfAssetsTotal) += state.interestDue; XRPL_ASSERT_PARTS( *vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal), "ripple::LoanSet::doApply",