diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 5b47dde6d1..56c762a5a4 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -828,6 +828,22 @@ accountSend( beast::Journal j, WaiveTransferFee waiveFee = WaiveTransferFee::No); +using MultiplePaymentDestinations = std::vector>; +/** Like accountSend, except one account is sending multiple payments (with the + * same asset!) simultaneously + * + * Calls static accountSendMultiIOU if saAmount represents Issue. + * Calls static accountSendMultiMPT if saAmount represents MPTIssue. + */ +[[nodiscard]] TER +accountSendMulti( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee = WaiveTransferFee::No); + [[nodiscard]] TER issueIOU( ApplyView& view, diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index f1b93c9a8c..f4d1198e10 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -560,26 +560,54 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({ {sfStartDate, soeREQUIRED}, {sfPaymentInterval, soeREQUIRED}, {sfGracePeriod, soeREQUIRED}, - {sfPeriodicPayment, soeREQUIRED}, {sfPreviousPaymentDate, soeDEFAULT}, {sfNextPaymentDueDate, soeOPTIONAL}, - {sfPaymentRemaining, soeDEFAULT}, - // The loan object tracks three values: - // - TotalValueOutstanding: The total amount owed by the borrower to - // the lender / vault. - // - PrincipalOutstanding: The portion of the TotalValueOutstanding - // that is from the prinicpal borrowed. - // - InterestOwed: The portion of the TotalValueOutstanding that - // represents interest owed to the vault. - // There are two additional values that can be computed from these: - // - InterestOutstanding: TotalValueOutstanding - PrincipalOutstanding + // The loan object tracks these values: + // + // - PaymentRemaining: The number of payments left in the loan. When it + // reaches 0, the loan is paid off, and all other relevant values + // must also be 0. + // + // - PeriodicPayment: The fixed, unrounded amount to be paid each + // interval. Stored with as much precision as possible. + // Payment transactions must round this value *UP*. + // + // - TotalValueOutstanding: The rounded total amount owed by the + // borrower to the lender / vault. + // + // - 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. + // + // There are additional values that can be computed from these: + // + // - InterestOutstanding = TotalValueOutstanding - PrincipalOutstanding // The total amount of interest still pending on the loan, // independent of management fees. - // - ManagementFeeOwed: InterestOutstanding - InterestOwed + // + // - ManagementFeeOwed = InterestOutstanding - InterestOwed // The amount of the total interest that will be sent to the // broker as management fees. + // + // - TrueTotalLoanValue = PaymentRemaining * PeriodicPayment + // The unrounded true total value of the loan. + // + // - TrueTotalPrincialOutstanding can be computed using the algorithm + // in the ripple::detail::loanPrincipalFromPeriodicPayment function. + // + // - TrueTotalInterestOutstanding = TrueTotalLoanValue - + // TrueTotalPrincipalOutstanding + // The unrounded true total interest remaining. + // + // Note the the "True" values may differ significantly from the tracked + // rounded values. + {sfPaymentRemaining, soeDEFAULT}, + {sfPeriodicPayment, soeREQUIRED}, {sfPrincipalOutstanding, soeDEFAULT}, - {sfReferencePrincipal, soeDEFAULT}, {sfTotalValueOutstanding, soeDEFAULT}, {sfInterestOwed, soeDEFAULT}, // Based on the original principal borrowed, used for diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 2fca180b7b..1911c7e31d 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -241,8 +241,7 @@ 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) +TYPED_SFIELD(sfInterestOwed, NUMBER, 17) // int32 TYPED_SFIELD(sfLoanScale, INT32, 1) diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 0372e50c92..5964486bcd 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -1910,6 +1910,87 @@ rippleSendIOU( return terResult; } +// Send regardless of limits. +// --> receivers: Amount/currency/issuer to deliver to receivers. +// <-- saActual: Amount actually cost to sender. Sender pays fees. +static TER +rippleSendMultiIOU( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + STAmount& actual, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + auto const issuer = asset.getIssuer(); + + XRPL_ASSERT( + !isXRP(senderID), "ripple::rippleSendMultiIOU : sender is not XRP"); + + // These may diverge + STAmount takeFromSender{asset}; + actual = takeFromSender; + + // Failures return immediately. + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{asset, r.second}; + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + XRPL_ASSERT( + !isXRP(receiverID), + "ripple::rippleSendMultiIOU : receiver is not XRP"); + + if (senderID == issuer || receiverID == issuer || issuer == noAccount()) + { + // Direct send: redeeming IOUs and/or sending own IOUs. + if (auto const ter = rippleCreditIOU( + view, senderID, receiverID, amount, false, j)) + return ter; + actual += amount; + // Do not add amount to takeFromSender, because rippleCreditIOU took + // it. + + continue; + } + + // Sending 3rd party IOUs: transit. + + // Calculate the amount to transfer accounting + // for any transfer fees if the fee is not waived: + STAmount actualSend = (waiveFee == WaiveTransferFee::Yes) + ? amount + : multiply(amount, transferRate(view, issuer)); + actual += actualSend; + takeFromSender += actualSend; + + JLOG(j.debug()) << "rippleSendMultiIOU> " << to_string(senderID) + << " - > " << to_string(receiverID) + << " : deliver=" << amount.getFullText() + << " cost=" << actual.getFullText(); + + if (TER const terResult = + rippleCreditIOU(view, issuer, receiverID, amount, true, j)) + return terResult; + } + + if (senderID != issuer && takeFromSender) + { + if (TER const terResult = rippleCreditIOU( + view, senderID, issuer, takeFromSender, true, j)) + return terResult; + } + + return tesSUCCESS; +} + static TER accountSendIOU( ApplyView& view, @@ -2034,6 +2115,165 @@ accountSendIOU( return terResult; } +static TER +accountSendMultiIOU( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + XRPL_ASSERT_PARTS( + receivers.size() > 1, + "ripple::accountSendMultiIOU", + "multiple recipients provided"); + + if (view.rules().enabled(fixAMMv1_1)) + { + if (asset.holds()) + { + return tecINTERNAL; + } + } + else + { + XRPL_ASSERT( + !asset.holds(), "ripple::accountSendMultiIOU : not MPT"); + } + + if (!asset.native()) + { + STAmount actual; + JLOG(j.trace()) << "accountSendMultiIOU: " << to_string(senderID) + << " sending " << receivers.size() << " IOUs"; + + return rippleSendMultiIOU( + view, senderID, asset, receivers, actual, j, waiveFee); + } + + /* XRP send which does not check reserve and can do pure adjustment. + * Note that sender or receiver may be null and this not a mistake; this + * setup could be used during pathfinding and it is carefully controlled to + * ensure that transfers are balanced. + */ + + SLE::pointer sender = senderID != beast::zero + ? view.peek(keylet::account(senderID)) + : SLE::pointer(); + + if (auto stream = j.trace()) + { + std::string sender_bal("-"); + + if (sender) + sender_bal = sender->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU> " << to_string(senderID) << " (" + << sender_bal << ") -> " << receivers.size() << " receivers."; + } + + // Failures return immediately. + STAmount takeFromSender{asset}; + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{asset, r.second}; + + takeFromSender += amount; + + if (view.rules().enabled(fixAMMv1_1)) + { + if (amount < beast::zero) + { + return tecINTERNAL; + } + } + else + { + XRPL_ASSERT( + amount >= beast::zero, + "ripple::accountSendMultiIOU : minimum amount"); + } + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + SLE::pointer receiver = receiverID != beast::zero + ? view.peek(keylet::account(receiverID)) + : SLE::pointer(); + + if (auto stream = j.trace()) + { + std::string receiver_bal("-"); + + if (receiver) + receiver_bal = + receiver->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU> " << to_string(senderID) << " -> " + << to_string(receiverID) << " (" << receiver_bal + << ") : " << amount.getFullText(); + } + + if (receiver) + { + // Increment XRP balance. + auto const rcvBal = receiver->getFieldAmount(sfBalance); + receiver->setFieldAmount(sfBalance, rcvBal + amount); + view.creditHook(xrpAccount(), receiverID, amount, -rcvBal); + + view.update(receiver); + } + + if (auto stream = j.trace()) + { + std::string receiver_bal("-"); + + if (receiver) + receiver_bal = + receiver->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU< " << to_string(senderID) << " -> " + << to_string(receiverID) << " (" << receiver_bal + << ") : " << amount.getFullText(); + } + } + + if (sender) + { + if (sender->getFieldAmount(sfBalance) < takeFromSender) + { + return TER{tecFAILED_PROCESSING}; + } + else + { + auto const sndBal = sender->getFieldAmount(sfBalance); + view.creditHook(senderID, xrpAccount(), takeFromSender, sndBal); + + // Decrement XRP balance. + sender->setFieldAmount(sfBalance, sndBal - takeFromSender); + view.update(sender); + } + } + + if (auto stream = j.trace()) + { + std::string sender_bal("-"); + std::string receiver_bal("-"); + + if (sender) + sender_bal = sender->getFieldAmount(sfBalance).getFullText(); + + stream << "accountSendMultiIOU< " << to_string(senderID) << " (" + << sender_bal << ") -> " << receivers.size() << " receivers."; + } + return tesSUCCESS; +} + static TER rippleCreditMPT( ApplyView& view, @@ -2162,6 +2402,102 @@ rippleSendMPT( return rippleCreditMPT(view, uSenderID, issuer, saActual, j); } +static TER +rippleSendMultiMPT( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + STAmount& actual, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + // Safe to get MPT since rippleSendMultiMPT is only called by + // accountSendMultiMPT + auto const issuer = asset.getIssuer(); + + auto const sle = + view.read(keylet::mptIssuance(asset.get().getMptID())); + if (!sle) + return tecOBJECT_NOT_FOUND; + + // These may diverge + STAmount takeFromSender{asset}; + actual = takeFromSender; + + for (auto const& r : receivers) + { + auto const& receiverID = r.first; + STAmount amount{asset, r.second}; + + XRPL_ASSERT( + senderID != receiverID, + "ripple::rippleSendMultiMPT : sender is not receiver"); + + XRPL_ASSERT( + amount >= beast::zero, + "ripple::rippleSendMultiMPT : minimum amount "); + + /* If we aren't sending anything or if the sender is the same as the + * receiver then we don't need to do anything. + */ + if (!amount || (senderID == receiverID)) + continue; + + if (senderID == issuer || receiverID == issuer) + { + // if sender is issuer, check that the new OutstandingAmount will + // not exceed MaximumAmount + if (senderID == issuer) + { + auto const sendAmount = amount.mpt().value(); + auto const maximumAmount = + sle->at(~sfMaximumAmount).value_or(maxMPTokenAmount); + if (sendAmount > maximumAmount - takeFromSender || + sle->getFieldU64(sfOutstandingAmount) > + maximumAmount - sendAmount - takeFromSender) + return tecPATH_DRY; + } + + // Direct send: redeeming MPTs and/or sending own MPTs. + if (auto const ter = + rippleCreditMPT(view, senderID, receiverID, amount, j)) + return ter; + actual += amount; + // Do not add amount to takeFromSender, because rippleCreditMPT took + // it + + continue; + } + + // Sending 3rd party MPTs: transit. + STAmount actualSend = (waiveFee == WaiveTransferFee::Yes) + ? amount + : multiply( + amount, + transferRate(view, amount.get().getMptID())); + actual += actualSend; + takeFromSender += actualSend; + + JLOG(j.debug()) << "rippleSendMultiMPT> " << to_string(senderID) + << " - > " << to_string(receiverID) + << " : deliver=" << amount.getFullText() + << " cost=" << actualSend.getFullText(); + + if (auto const terResult = + rippleCreditMPT(view, issuer, receiverID, amount, j)) + return terResult; + } + if (senderID != issuer && takeFromSender) + { + if (TER const terResult = + rippleCreditMPT(view, senderID, issuer, takeFromSender, j)) + return terResult; + } + + return tesSUCCESS; +} + static TER accountSendMPT( ApplyView& view, @@ -2187,6 +2523,23 @@ accountSendMPT( view, uSenderID, uReceiverID, saAmount, saActual, j, waiveFee); } +static TER +accountSendMultiMPT( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + XRPL_ASSERT(asset.holds(), "ripple::accountSendMultiMPT : MPT"); + + STAmount actual; + + return rippleSendMultiMPT( + view, senderID, asset, receivers, actual, j, waiveFee); +} + TER accountSend( ApplyView& view, @@ -2208,6 +2561,31 @@ accountSend( saAmount.asset().value()); } +TER +accountSendMulti( + ApplyView& view, + AccountID const& senderID, + Asset const& asset, + MultiplePaymentDestinations const& receivers, + beast::Journal j, + WaiveTransferFee waiveFee) +{ + XRPL_ASSERT_PARTS( + receivers.size() > 1, + "ripple::accountSendMulti", + "multiple recipients provided"); + return std::visit( + [&](TIss const& issue) { + if constexpr (std::is_same_v) + return accountSendMultiIOU( + view, senderID, asset, receivers, j, waiveFee); + else + return accountSendMultiMPT( + view, senderID, asset, receivers, j, waiveFee); + }, + asset.value()); +} + static bool updateTrustLine( ApplyView& view, diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 3c1f022186..163a2e5d52 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -110,12 +110,11 @@ class Loan_test : public beast::unit_test::suite { std::uint32_t previousPaymentDate = 0; NetClock::time_point startDate = {}; - std::optional nextPaymentDate = 0; + std::uint32_t nextPaymentDate = 0; std::uint32_t paymentRemaining = 0; std::int32_t const loanScale = 0; Number totalValue = 0; Number principalOutstanding = 0; - Number referencePrincipal = 0; Number interestOwed = 0; Number periodicPayment = 0; std::uint32_t flags = 0; @@ -206,16 +205,40 @@ class Loan_test : public beast::unit_test::suite } } + void + checkPayment( + std::int32_t loanScale, + jtx::Account const& account, + jtx::PrettyAmount const& balanceBefore, + STAmount const& expectedPayment, + jtx::PrettyAmount const& adjustment) const + { + auto const borrowerScale = + std::max(loanScale, balanceBefore.number().exponent()); + + STAmount const balanceChangeAmount{ + broker.asset, + roundToAsset( + broker.asset, expectedPayment + adjustment, borrowerScale)}; + { + auto const difference = roundToScale( + env.balance(account, broker.asset) - + (balanceBefore - balanceChangeAmount), + borrowerScale); + env.test.BEAST_EXPECT( + roundToScale(difference, loanScale) >= beast::zero); + } + } + /** Checks both the loan and broker expect states against the ledger */ void operator()( std::uint32_t previousPaymentDate, - std::optional nextPaymentDate, + std::uint32_t nextPaymentDate, std::uint32_t paymentRemaining, Number const& loanScale, Number const& totalValue, Number const& principalOutstanding, - Number const& referencePrincipal, Number const& interestOwed, Number const& periodicPayment, std::uint32_t flags) const @@ -225,17 +248,18 @@ class Loan_test : public beast::unit_test::suite { env.test.BEAST_EXPECT( loan->at(sfPreviousPaymentDate) == previousPaymentDate); - env.test.BEAST_EXPECT( - loan->at(~sfNextPaymentDueDate) == nextPaymentDate); env.test.BEAST_EXPECT( loan->at(sfPaymentRemaining) == paymentRemaining); + if (paymentRemaining == 0) + env.test.BEAST_EXPECT(!loan->at(~sfNextPaymentDueDate)); + else + env.test.BEAST_EXPECT( + loan->at(sfNextPaymentDueDate) == nextPaymentDate); env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale); env.test.BEAST_EXPECT( loan->at(sfTotalValueOutstanding) == totalValue); env.test.BEAST_EXPECT( loan->at(sfPrincipalOutstanding) == principalOutstanding); - env.test.BEAST_EXPECT( - loan->at(sfReferencePrincipal) == referencePrincipal); env.test.BEAST_EXPECT(loan->at(sfInterestOwed) == interestOwed); env.test.BEAST_EXPECT( loan->at(sfPeriodicPayment) == periodicPayment); @@ -287,7 +311,6 @@ class Loan_test : public beast::unit_test::suite state.loanScale, state.totalValue, state.principalOutstanding, - state.referencePrincipal, state.interestOwed, state.periodicPayment, state.flags); @@ -360,12 +383,11 @@ class Loan_test : public beast::unit_test::suite LoanState state{ .previousPaymentDate = loan->at(sfPreviousPaymentDate), .startDate = tp{d{loan->at(sfStartDate)}}, - .nextPaymentDate = loan->at(~sfNextPaymentDueDate), + .nextPaymentDate = loan->at(sfNextPaymentDueDate), .paymentRemaining = loan->at(sfPaymentRemaining), .loanScale = loan->at(sfLoanScale), .totalValue = loan->at(sfTotalValueOutstanding), .principalOutstanding = loan->at(sfPrincipalOutstanding), - .referencePrincipal = loan->at(sfReferencePrincipal), .interestOwed = loan->at(sfInterestOwed), .periodicPayment = loan->at(sfPeriodicPayment), .flags = loan->at(sfFlags), @@ -373,9 +395,8 @@ class Loan_test : public beast::unit_test::suite .interestRate = TenthBips32{loan->at(sfInterestRate)}, }; BEAST_EXPECT(state.previousPaymentDate == 0); - if (BEAST_EXPECT(state.nextPaymentDate)) - BEAST_EXPECT( - tp{d{*state.nextPaymentDate}} == state.startDate + 600s); + BEAST_EXPECT( + tp{d{state.nextPaymentDate}} == state.startDate + 600s); BEAST_EXPECT(state.paymentRemaining == 12); BEAST_EXPECT( state.principalOutstanding == broker.asset(1000).value()); @@ -608,7 +629,6 @@ class Loan_test : public beast::unit_test::suite auto const loanProperties = computeLoanProperties( broker.asset, state.principalOutstanding, - state.referencePrincipal, state.interestRate, state.paymentInterval, state.paymentRemaining, @@ -621,7 +641,6 @@ class Loan_test : public beast::unit_test::suite principalRequest.exponent(), loanProperties.totalValueOutstanding, principalRequest, - principalRequest, loanProperties.interestOwedToVault, loanProperties.periodicPayment, loanFlags | 0); @@ -679,7 +698,6 @@ class Loan_test : public beast::unit_test::suite principalRequest.exponent(), loanProperties.totalValueOutstanding, principalRequest, - principalRequest, loanProperties.interestOwedToVault, loanProperties.periodicPayment, loanFlags | 0); @@ -1232,9 +1250,7 @@ class Loan_test : public beast::unit_test::suite verifyLoanStatus(state); } - BEAST_EXPECT(state.nextPaymentDate); - auto const nextDueDate = - tp{d{state.nextPaymentDate.value_or(0)}}; + auto const nextDueDate = tp{d{state.nextPaymentDate}}; // Can't default the loan yet. The grace period hasn't // expired @@ -1264,7 +1280,6 @@ class Loan_test : public beast::unit_test::suite state.paymentRemaining = 0; state.totalValue = 0; state.principalOutstanding = 0; - state.referencePrincipal = 0; state.interestOwed = 0; verifyLoanStatus(state); @@ -1279,8 +1294,9 @@ class Loan_test : public beast::unit_test::suite }; }; - auto immediatePayoff = [&](std::uint32_t baseFlag) { - return [&, baseFlag]( + auto singlePayment = [&](STAmount const& payoffAmount, + std::uint32_t baseFlag) { + return [&, payoffAmount, baseFlag]( Keylet const& loanKeylet, VerifyLoanStatus const& verifyLoanStatus) { // toEndOfLife @@ -1323,6 +1339,58 @@ class Loan_test : public beast::unit_test::suite auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); + BEAST_EXPECT(payoffAmount > state.principalOutstanding); + // Try to pay a little extra to show that it's _not_ + // taken + auto const transactionAmount = payoffAmount + broker.asset(10); + BEAST_EXPECT( + transactionAmount == + broker.asset(Number(1050000114155251, -12))); + env(pay(borrower, loanKeylet.key, transactionAmount)); + + env.close(); + + // Need to account for fees if the loan is in XRP + PrettyAmount adjustment = broker.asset(0); + if (broker.asset.raw().native()) + { + adjustment = env.current()->fees().base; + } + + state.paymentRemaining = 0; + state.principalOutstanding = 0; + state.totalValue = 0; + state.interestOwed = 0; + state.previousPaymentDate = state.nextPaymentDate; + verifyLoanStatus(state); + + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + payoffAmount, + adjustment); + + // Can't impair or default a paid off loan + env(manage(lender, loanKeylet.key, tfLoanImpair), + ter(tecNO_PERMISSION)); + env(manage(lender, loanKeylet.key, tfLoanDefault), + ter(tecNO_PERMISSION)); + }; + }; + + auto immediatePayoff = [&](std::uint32_t baseFlag) { + return [&, baseFlag]( + Keylet const& loanKeylet, + VerifyLoanStatus const& verifyLoanStatus) { + // toEndOfLife + // + auto const state = + getCurrentState(env, broker, loanKeylet, verifyLoanStatus); + env.close(state.startDate + 20s); + auto const loanAge = (env.now() - state.startDate).count(); + BEAST_EXPECT(loanAge == 30); + // Full payoff amount will consist of // 1. principal outstanding (1000) // 2. accrued interest (at 12%) @@ -1350,65 +1418,21 @@ class Loan_test : public beast::unit_test::suite broker.asset, state.principalOutstanding * Number(36, -3)}; BEAST_EXPECT(prepaymentPenalty == broker.asset(36)); STAmount const closePaymentFee = broker.asset(4); - auto const payoffAmount = principalOutstanding + - accruedInterest + prepaymentPenalty + closePaymentFee; + auto const payoffAmount = roundToScale( + principalOutstanding + accruedInterest + prepaymentPenalty + + closePaymentFee, + state.loanScale); BEAST_EXPECT( payoffAmount == broker.asset(Number(1040000114155251, -12))); - BEAST_EXPECT(payoffAmount > state.principalOutstanding); - // The terms of this loan actually make the early payoff more - // expensive than just making payments + + // The terms of this loan actually make the early payoff + // more expensive than just making payments BEAST_EXPECT( payoffAmount > state.paymentRemaining * (state.periodicPayment + broker.asset(2).value())); - // Try to pay a little extra to show that it's _not_ - // taken - auto const transactionAmount = payoffAmount + broker.asset(10); - BEAST_EXPECT( - transactionAmount == - broker.asset(Number(1050000114155251, -12))); - env(pay(borrower, loanKeylet.key, transactionAmount)); - env.close(); - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.raw().native()) - { - adjustment = env.current()->fees().base; - } - - state.paymentRemaining = 0; - state.principalOutstanding = 0; - state.referencePrincipal = 0; - state.totalValue = 0; - state.interestOwed = 0; - if (BEAST_EXPECT(state.nextPaymentDate)) - state.previousPaymentDate = *state.nextPaymentDate; - state.nextPaymentDate.reset(); - verifyLoanStatus(state); - - STAmount const balanceChangeAmount{ - broker.asset, - roundToAsset(broker.asset, payoffAmount, state.loanScale)}; - { - auto const difference = - roundToScale( - env.balance(borrower, broker.asset), - state.loanScale) - - (borrowerBalanceBeforePayment - balanceChangeAmount - - adjustment); - BEAST_EXPECT(difference == beast::zero); - BEAST_EXPECT( - roundToScale(difference, state.loanScale) == - beast::zero); - } - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), - ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), - ter(tecNO_PERMISSION)); + singlePayment(payoffAmount, baseFlag); }; }; @@ -1418,43 +1442,9 @@ class Loan_test : public beast::unit_test::suite VerifyLoanStatus const& verifyLoanStatus) { // toEndOfLife // - auto state = + + auto const state = getCurrentState(env, broker, loanKeylet, verifyLoanStatus); - BEAST_EXPECT(state.flags == baseFlag); - env.close(state.startDate + 20s); - auto const loanAge = (env.now() - state.startDate).count(); - BEAST_EXPECT(loanAge == 30); - - verifyLoanStatus(state); - - // Send some bogus pay transactions - env(pay(borrower, - keylet::loan(uint256(0)).key, - broker.asset(10)), - ter(temINVALID)); - env(pay(borrower, loanKeylet.key, broker.asset(-100)), - ter(temBAD_AMOUNT)); - env(pay(borrower, broker.brokerID, broker.asset(100)), - ter(tecNO_ENTRY)); - env(pay(evan, loanKeylet.key, broker.asset(500)), - ter(tecNO_PERMISSION)); - - { - auto const otherAsset = - broker.asset.raw() == assets[0].raw() ? assets[1] - : assets[0]; - env(pay(borrower, loanKeylet.key, otherAsset(100)), - ter(tecWRONG_ASSET)); - } - - // Amount doesn't cover a single payment - env(pay(borrower, loanKeylet.key, STAmount{broker.asset, 1}), - ter(tecINSUFFICIENT_PAYMENT)); - - // Get the balance after these failed transactions take - // fees - auto const borrowerBalanceBeforePayment = - env.balance(borrower, broker.asset); // Make all the payments in one transaction // service fee is 2 @@ -1467,51 +1457,7 @@ class Loan_test : public beast::unit_test::suite broker.asset(Number(1024014840139457, -12))); BEAST_EXPECT(payoffAmount > state.principalOutstanding); - // Try to pay a little extra to show that it's _not_ - // taken - auto const transactionAmount = payoffAmount + broker.asset(10); - BEAST_EXPECT( - transactionAmount == - broker.asset(Number(1034014840139457, -12))); - env(pay(borrower, loanKeylet.key, transactionAmount)); - - env.close(); - - // Need to account for fees if the loan is in XRP - PrettyAmount adjustment = broker.asset(0); - if (broker.asset.raw().native()) - { - adjustment = env.current()->fees().base; - } - - state.paymentRemaining = 0; - state.principalOutstanding = 0; - state.referencePrincipal = 0; - state.totalValue = 0; - state.interestOwed = 0; - if (BEAST_EXPECT(state.nextPaymentDate)) - state.previousPaymentDate = *state.nextPaymentDate + - state.paymentInterval * - (startingPayments - 1); // 9280-2680=6600 - state.nextPaymentDate.reset(); - verifyLoanStatus(state); - - STAmount const balanceChangeAmount{ - broker.asset, - roundToAsset(broker.asset, payoffAmount, state.loanScale)}; - { - auto const difference = - env.balance(borrower, broker.asset) - - (borrowerBalanceBeforePayment - balanceChangeAmount - - adjustment); - BEAST_EXPECT(difference == beast::zero); - } - - // Can't impair or default a paid off loan - env(manage(lender, loanKeylet.key, tfLoanImpair), - ter(tecNO_PERMISSION)); - env(manage(lender, loanKeylet.key, tfLoanDefault), - ter(tecNO_PERMISSION)); + singlePayment(payoffAmount, baseFlag); }; }; @@ -1690,8 +1636,11 @@ class Loan_test : public beast::unit_test::suite // may drift as payments are made BEAST_EXPECT( roundedPeriodicPayment == - broker.asset( - Number(8333457001162141, -14), Number::upward)); + roundToScale( + broker.asset( + Number(8333457001162141, -14), Number::upward), + state.loanScale, + Number::upward)); // 83334570.01162141 // Include the service fee STAmount const totalDue = roundToScale( @@ -1729,7 +1678,6 @@ class Loan_test : public beast::unit_test::suite state.loanScale, state.totalValue, state.principalOutstanding, - state.referencePrincipal, state.periodicPayment, periodicRate, state.paymentRemaining); @@ -1776,10 +1724,6 @@ class Loan_test : public beast::unit_test::suite Number::upward), state.loanScale, Number::upward)); - BEAST_EXPECT( - !paymentComponents.final || - paymentComponents.rawPrincipal == - state.referencePrincipal); BEAST_EXPECT( paymentComponents.roundedPrincipal >= Number(0) && paymentComponents.roundedPrincipal <= @@ -1795,9 +1739,13 @@ class Loan_test : public beast::unit_test::suite state.periodicPayment); BEAST_EXPECT( paymentComponents.final || - paymentComponents.roundedPrincipal + - paymentComponents.roundedInterest == - roundedPeriodicPayment); + roundedPeriodicPayment >= + (paymentComponents.roundedPrincipal + + paymentComponents.roundedInterest) && + roundedPeriodicPayment - + (paymentComponents.roundedPrincipal + + paymentComponents.roundedInterest) < + 2); auto const borrowerBalanceBeforePayment = env.balance(borrower, broker.asset); @@ -1821,35 +1769,25 @@ class Loan_test : public beast::unit_test::suite } // Check the result - auto const borrowerBalance = - env.balance(borrower, broker.asset); - auto const expectedBalance = borrowerBalanceBeforePayment - - totalDueAmount - adjustment; - BEAST_EXPECT( - borrowerBalance == expectedBalance || - (!broker.asset.raw().native() && - broker.asset.raw().holds() && - ((borrowerBalance - expectedBalance) / - expectedBalance < - Number(1, -4)))); + verifyLoanStatus.checkPayment( + state.loanScale, + borrower, + borrowerBalanceBeforePayment, + totalDueAmount, + adjustment); --state.paymentRemaining; - if (BEAST_EXPECT(state.nextPaymentDate)) + state.previousPaymentDate = state.nextPaymentDate; + if (paymentComponents.final) { - state.previousPaymentDate = *state.nextPaymentDate; - if (paymentComponents.final) - { - state.nextPaymentDate.reset(); - state.paymentRemaining = 0; - } - else - { - *state.nextPaymentDate += state.paymentInterval; - } + state.paymentRemaining = 0; + } + else + { + state.nextPaymentDate += state.paymentInterval; } state.principalOutstanding -= paymentComponents.roundedPrincipal; - state.referencePrincipal -= paymentComponents.rawPrincipal; state.totalValue -= paymentComponents.roundedPrincipal + paymentComponents.roundedInterest; state.interestOwed -= valueMinusFee( @@ -2104,7 +2042,6 @@ class Loan_test : public beast::unit_test::suite BEAST_EXPECT(loan[sfPaymentRemaining] == 1); BEAST_EXPECT(!loan.isMember(sfPreviousPaymentDate)); BEAST_EXPECT(loan[sfPrincipalOutstanding] == "1000000000"); - BEAST_EXPECT(loan[sfReferencePrincipal] == "1000000000"); BEAST_EXPECT(loan[sfTotalValueOutstanding] == "1000000000"); BEAST_EXPECT(loan[sfLoanScale] == -6); BEAST_EXPECT( diff --git a/src/xrpld/app/misc/LendingHelpers.h b/src/xrpld/app/misc/LendingHelpers.h index 69b77bf470..b9cd00de83 100644 --- a/src/xrpld/app/misc/LendingHelpers.h +++ b/src/xrpld/app/misc/LendingHelpers.h @@ -105,6 +105,12 @@ loanPeriodicPayment( std::uint32_t paymentInterval, std::uint32_t paymentsRemaining); +Number +loanPrincipalFromPeriodicPayment( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentsRemaining); + Number loanLatePaymentInterest( Number const& principalOutstanding, @@ -134,7 +140,6 @@ computePaymentComponents( std::int32_t scale, Number const& totalValueOutstanding, Number const& principalOutstanding, - Number const& referencePrincipal, Number const& periodicPayment, Number const& periodicRate, std::uint32_t paymentRemaining) @@ -154,27 +159,27 @@ computePaymentComponents( totalValueOutstanding <= roundedPeriodicPayment) { // If there's only one payment left, we need to pay off each of the loan - // parts. - // rawInterest could be < 0 because we're computing it with the rounded - // value outstanding, but for the last payment, we also don't care. - 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::computePaymentComponents", - "last payment is complete"); + // 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); + // Pay everything off return { - .rawInterest = std::max(Number{}, rawInterest), - .rawPrincipal = referencePrincipal, - .roundedInterest = roundedInterest, + .rawInterest = interest, + .rawPrincipal = principalOutstanding, + .roundedInterest = interest, .roundedPrincipal = principalOutstanding, - .roundedPayment = roundedInterest + principalOutstanding, + .roundedPayment = interest + principalOutstanding, .final = true}; } + + Number const rawValueOutstanding = periodicPayment * paymentRemaining; + Number const rawPrincipalOutstanding = loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); + Number const rawInterestOutstanding = + rawValueOutstanding - rawInterestOutstanding; + /* * From the spec, once the periodicPayment is computed: * @@ -182,34 +187,63 @@ computePaymentComponents( * interest = principalOutstanding * periodicRate * principal = periodicPayment - interest */ - Number const rawInterest = referencePrincipal * periodicRate; + 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 <= referencePrincipal, + rawPrincipal >= 0 && rawPrincipal <= rawPrincipalOutstanding, "ripple::detail::computePaymentComponents", "valid raw principal"); + /* + 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 = [&]() { - // Round the raw principal after payment - auto const roundedPrincipalTarget = - roundToAsset(asset, referencePrincipal - rawPrincipal, scale); - // Determine the total value after payment - auto const totalValueTarget = - totalValueOutstanding - roundedPeriodicPayment; - // We want to get the principal down to the smaller of the two targets - auto const principalTarget = - std::min(roundedPrincipalTarget, totalValueTarget); - // What will get us to our target? - auto const p = principalOutstanding - principalTarget; + auto const p = roundToAsset( + asset, + // Compute the delta that will get the tracked principalOutstanding + // amount as close to the raw principal amount after the payment as + // possible. + principalOutstanding - (rawPrincipalOutstanding - rawPrincipal), + scale, + Number::downward); XRPL_ASSERT_PARTS( - p >= 0 && p <= roundedPeriodicPayment, + p > 0, + "rippled::detail::computePaymentComponents", + "principal part positive"); + 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"); + // Make sure nothing goes negative if (p > roundedPeriodicPayment || p > principalOutstanding) return std::min(roundedPeriodicPayment, principalOutstanding); @@ -223,7 +257,28 @@ computePaymentComponents( // Zero interest means ZERO interest if (periodicRate == 0) return Number{}; - auto i = roundedPeriodicPayment - roundedPrincipal; + + // 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; + + // Compute the delta that will get the untracked interestOutstanding + // amount as close as possible to the raw 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"); + + // 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{}; @@ -240,6 +295,10 @@ computePaymentComponents( 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"); return { .rawInterest = rawInterest, @@ -263,16 +322,15 @@ struct PaymentComponentsPlus : public PaymentComponents } }; -template +template LoanPaymentParts doPayment( PaymentComponentsPlus const& payment, NumberProxy& totalValueOutstandingProxy, NumberProxy& principalOutstandingProxy, - NumberProxy& referencePrincipalProxy, - Int32Proxy& paymentRemainingProxy, - Int32Proxy& prevPaymentDateProxy, - Int32OptionalProxy& nextDueDateProxy, + UInt32Proxy& paymentRemainingProxy, + UInt32Proxy& prevPaymentDateProxy, + UInt32OptionalProxy& nextDueDateProxy, std::uint32_t paymentInterval) { XRPL_ASSERT_PARTS( @@ -285,10 +343,6 @@ doPayment( { if (payment.final) { - XRPL_ASSERT_PARTS( - referencePrincipalProxy == payment.rawPrincipal, - "ripple::detail::doPayment", - "Full reference principal payment"); XRPL_ASSERT_PARTS( principalOutstandingProxy == payment.roundedPrincipal, "ripple::detail::doPayment", @@ -307,10 +361,6 @@ doPayment( } else { - XRPL_ASSERT_PARTS( - referencePrincipalProxy > payment.rawPrincipal, - "ripple::detail::doPayment", - "Full reference principal payment"); XRPL_ASSERT_PARTS( principalOutstandingProxy > payment.roundedPrincipal, "ripple::detail::doPayment", @@ -323,16 +373,23 @@ doPayment( paymentRemainingProxy -= 1; prevPaymentDateProxy = *nextDueDateProxy; - // STObject::OptionalField does not define operator+=, and I don't - // want to add one right now. + // STObject::OptionalField does not define operator+=, so do it the + // old-fashioned way. nextDueDateProxy = *nextDueDateProxy + paymentInterval; } } - referencePrincipalProxy -= payment.rawPrincipal; principalOutstandingProxy -= payment.roundedPrincipal; totalValueOutstandingProxy -= totalValueDelta; + XRPL_ASSERT_PARTS( + // Use an explicit cast because the template parameter can be + // ValueProxy or Number + static_cast(principalOutstandingProxy) <= + static_cast(totalValueOutstandingProxy), + "ripple::detail::doPayment", + "principal does not exceed total"); + return LoanPaymentParts{ .principalPaid = payment.roundedPrincipal, .interestPaid = payment.roundedInterest, @@ -340,6 +397,127 @@ doPayment( .feeToPay = payment.fee}; } +template < + AssetType A, + class NumberProxy, + class UInt32Proxy, + class UInt32OptionalProxy> +Expected +doOverpayment( + 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, + 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( + asset, + principalOutstandingProxy, + interestRate, + paymentInterval, + paymentRemainingProxy, + managementFeeRate); + + auto const accumulatedError = + oldLoanProperties.totalValueOutstanding - totalValueOutstandingProxy; + + { + // 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; + } +} + /* Handle possible late payments. * * If this function processed a late payment, the return value will be @@ -425,7 +603,7 @@ handleFullPayment( A const& asset, ApplyView& view, Number const& principalOutstanding, - Number const& referencePrincipal, + Number const& periodicPayment, std::uint32_t paymentRemaining, std::uint32_t prevPaymentDate, std::uint32_t const startDate, @@ -442,17 +620,20 @@ handleFullPayment( // If this is the last payment, it has to be a regular payment return Unexpected(tesSUCCESS); + Number const rawValueOutstanding = periodicPayment * paymentRemaining; + Number const rawPrincipalOutstanding = loanPrincipalFromPeriodicPayment( + periodicPayment, periodicRate, paymentRemaining); + auto const totalInterest = calculateFullPaymentInterest( asset, - referencePrincipal, + rawPrincipalOutstanding, periodicRate, view.parentCloseTime(), paymentInterval, prevPaymentDate, startDate, closeInterestRate, - loanScale, - closePaymentFee); + loanScale); auto const closeFullPayment = principalOutstanding + totalInterest + closePaymentFee; @@ -467,11 +648,11 @@ handleFullPayment( PaymentComponentsPlus const result{ PaymentComponents{ .rawInterest = - principalOutstanding + totalInterest - referencePrincipal, - .rawPrincipal = referencePrincipal, + principalOutstanding + totalInterest - rawPrincipalOutstanding, + .rawPrincipal = rawPrincipalOutstanding, .roundedInterest = totalInterest, .roundedPrincipal = principalOutstanding, - .roundedPayment = closeFullPayment, + .roundedPayment = principalOutstanding + totalInterest, .final = true}, // A full payment only pays the single close payment fee closePaymentFee, @@ -511,7 +692,6 @@ LoanProperties computeLoanProperties( A const& asset, Number const& principalOutstanding, - Number const& referencePrincipal, TenthBips32 interestRate, std::uint32_t paymentInterval, std::uint32_t paymentsRemaining, @@ -520,7 +700,7 @@ computeLoanProperties( auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval); XRPL_ASSERT( interestRate == 0 || periodicRate > 0, - "ripple::loanMakePayment : valid rate"); + "ripple::computeLoanProperties : valid rate"); auto const periodicPayment = detail::loanPeriodicPayment( principalOutstanding, periodicRate, paymentsRemaining); @@ -539,7 +719,8 @@ computeLoanProperties( periodicPayment * paymentsRemaining}; }(); // Base the loan scale on the total value, since that's going to be the - // biggest number involved + // biggest number involved (barring unusual parameters for late, full, or + // over payments) auto const loanScale = totalValueOutstanding.exponent(); auto const firstPaymentPrincipal = [&]() { @@ -550,20 +731,13 @@ computeLoanProperties( 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 - paymentComponents.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 paymentComponents.rawPrincipal; + // The rounded principal part needs to be large enough to affect the + // principal. What to do if not is left to the caller + return paymentComponents.roundedPrincipal; }(); auto const interestOwedToVault = valueMinusFee( @@ -588,22 +762,21 @@ template Number calculateFullPaymentInterest( A const& asset, - Number const& referencePrincipal, + 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, - Number const& closePaymentFee) + 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( - referencePrincipal, + rawPrincipalOutstanding, periodicRate, parentCloseTime, startDate, @@ -613,9 +786,10 @@ calculateFullPaymentInterest( XRPL_ASSERT( accruedInterest >= 0, "ripple::detail::handleFullPayment : valid accrued interest"); + auto const prepaymentPenalty = roundToAsset( asset, - tenthBipsOfValue(referencePrincipal, closeInterestRate), + tenthBipsOfValue(rawPrincipalOutstanding, closeInterestRate), loanScale); XRPL_ASSERT( prepaymentPenalty >= 0, @@ -624,6 +798,36 @@ calculateFullPaymentInterest( 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 @@ -843,7 +1047,6 @@ loanMakePayment( std::int32_t const loanScale = loan->at(sfLoanScale); auto totalValueOutstandingProxy = loan->at(sfTotalValueOutstanding); - auto referencePrincipalProxy = loan->at(sfReferencePrincipal); TenthBips32 const interestRate{loan->at(sfInterestRate)}; TenthBips32 const lateInterestRate{loan->at(sfLateInterestRate)}; @@ -879,7 +1082,6 @@ loanMakePayment( loanScale, totalValueOutstandingProxy, principalOutstandingProxy, - referencePrincipalProxy, periodicPayment, periodicRate, paymentRemainingProxy), @@ -903,7 +1105,6 @@ loanMakePayment( *latePaymentComponents, totalValueOutstandingProxy, principalOutstandingProxy, - referencePrincipalProxy, paymentRemainingProxy, prevPaymentDateProxy, nextDueDateProxy, @@ -911,8 +1112,8 @@ loanMakePayment( } else if (latePaymentComponents.error()) // error() will be the TER returned if a payment is not made. It will - // only evaluate to true if it's an error. Otherwise, tesSUCCESS means - // nothing was done, so continue. + // only evaluate to true if it's unsuccessful. Otherwise, tesSUCCESS + // means nothing was done, so continue. return Unexpected(latePaymentComponents.error()); // ------------------------------------------------------------- @@ -924,7 +1125,7 @@ loanMakePayment( asset, view, principalOutstandingProxy, - referencePrincipalProxy, + periodicPayment, paymentRemainingProxy, prevPaymentDateProxy, startDate, @@ -940,15 +1141,14 @@ loanMakePayment( *fullPaymentComponents, totalValueOutstandingProxy, principalOutstandingProxy, - referencePrincipalProxy, paymentRemainingProxy, prevPaymentDateProxy, nextDueDateProxy, paymentInterval); else if (fullPaymentComponents.error()) // error() will be the TER returned if a payment is not made. It will - // only evaluate to true if it's an error. Otherwise, tesSUCCESS means - // nothing was done, so continue. + // only evaluate to true if it's unsuccessful. Otherwise, tesSUCCESS + // means nothing was done, so continue. return Unexpected(fullPaymentComponents.error()); // ------------------------------------------------------------- @@ -973,7 +1173,6 @@ loanMakePayment( periodic, totalValueOutstandingProxy, principalOutstandingProxy, - referencePrincipalProxy, paymentRemainingProxy, prevPaymentDateProxy, nextDueDateProxy, @@ -990,7 +1189,6 @@ loanMakePayment( loanScale, totalValueOutstandingProxy, principalOutstandingProxy, - referencePrincipalProxy, periodicPayment, periodicRate, paymentRemainingProxy), @@ -1015,7 +1213,6 @@ loanMakePayment( nextPayment, totalValueOutstandingProxy, principalOutstandingProxy, - referencePrincipalProxy, paymentRemainingProxy, prevPaymentDateProxy, nextDueDateProxy, @@ -1062,6 +1259,8 @@ loanMakePayment( Number const payment = overpayment - fee; + // TODO: Is the overpaymentInterestRate an APR or flat? + Number const interest = tenthBipsOfValue(payment, overpaymentInterestRate); Number const roundedInterest = roundToAsset(asset, interest, loanScale); @@ -1074,86 +1273,35 @@ loanMakePayment( .roundedPrincipal = payment - roundedInterest, .roundedPayment = payment, .extra = true}, - fee}; + fee, + roundedInterest}; // Don't process an overpayment if the whole amount (or more!) // gets eaten by fees and interest. if (overpaymentComponents.rawPrincipal > 0 && overpaymentComponents.roundedPrincipal > 0) { - Number const totalInterestOutstandingBefore = - totalValueOutstandingProxy - principalOutstandingProxy; - - auto const oldLoanProperties = computeLoanProperties( - asset, - principalOutstandingProxy, - referencePrincipalProxy, - interestRate, - paymentInterval, - paymentRemainingProxy, - managementFeeRate); - - auto const accumulatedError = - oldLoanProperties.totalValueOutstanding - - totalValueOutstandingProxy; - - totalParts += detail::doPayment( - overpaymentComponents, - totalValueOutstandingProxy, - principalOutstandingProxy, - referencePrincipalProxy, - paymentRemainingProxy, - prevPaymentDateProxy, - nextDueDateProxy, - paymentInterval); - - auto const newLoanProperties = computeLoanProperties( - asset, - principalOutstandingProxy, - referencePrincipalProxy, - interestRate, - paymentInterval, - paymentRemainingProxy, - managementFeeRate); - - if (newLoanProperties.firstPaymentPrincipal <= 0 && - *principalOutstandingProxy > 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."; - return Unexpected(tecLIMIT_EXCEEDED); - } - // 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(tecINTERNAL); - // LCOV_EXCL_STOP - } - - totalValueOutstandingProxy = - newLoanProperties.totalValueOutstanding; - loan->at(sfPeriodicPayment) = newLoanProperties.periodicPayment; - loan->at(sfInterestOwed) = newLoanProperties.interestOwedToVault; - - auto const totalInterestOutstandingAfter = - totalValueOutstandingProxy - principalOutstandingProxy; - - totalParts.valueChange += totalInterestOutstandingBefore - - totalInterestOutstandingAfter + - overpaymentComponents.roundedInterest + accumulatedError; + auto periodicPaymentProxy = loan->at(sfPeriodicPayment); + if (auto const overResult = detail::doOverpayment( + asset, + view, + overpaymentComponents, + totalValueOutstandingProxy, + principalOutstandingProxy, + periodicPaymentProxy, + interestRate, + paymentInterval, + paymentRemainingProxy, + prevPaymentDateProxy, + nextDueDateProxy, + managementFeeRate, + j)) + totalParts += *overResult; + else if (overResult.error()) + // error() will be the TER returned if a payment is not made. It + // will only evaluate to true if it's unsuccessful. Otherwise, + // tesSUCCESS means nothing was done, so continue. + return Unexpected(overResult.error()); } } diff --git a/src/xrpld/app/misc/detail/LendingHelpers.cpp b/src/xrpld/app/misc/detail/LendingHelpers.cpp index d32413437f..447c940cc0 100644 --- a/src/xrpld/app/misc/detail/LendingHelpers.cpp +++ b/src/xrpld/app/misc/detail/LendingHelpers.cpp @@ -46,6 +46,31 @@ loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) namespace detail { +Number +computeRaisedRate(Number const& periodicRate, std::uint32_t paymentsRemaining) +{ + /* + * This formula is from the XLS-66 spec, section 3.2.4.1.1 (Regular + * Payment), though "raisedRate" is computed only once and used twice. + */ + return power(1 + periodicRate, paymentsRemaining); +} + +Number +computePaymentFactor( + Number const& periodicRate, + std::uint32_t paymentsRemaining) +{ + /* + * This formula is from the XLS-66 spec, section 3.2.4.1.1 (Regular + * Payment), though "raisedRate" is computed only once and used twice. + */ + Number const raisedRate = + computeRaisedRate(periodicRate, paymentsRemaining); + + return (periodicRate * raisedRate) / (raisedRate - 1); +} + Number loanPeriodicPayment( Number const& principalOutstanding, @@ -61,11 +86,10 @@ loanPeriodicPayment( /* * This formula is from the XLS-66 spec, section 3.2.4.1.1 (Regular - * Payment), though "raisedRate" is computed only once and used twice. + * Payment). */ - Number const raisedRate = power(1 + periodicRate, paymentsRemaining); - - return principalOutstanding * periodicRate * raisedRate / (raisedRate - 1); + return principalOutstanding * + computePaymentFactor(periodicRate, paymentsRemaining); } Number @@ -87,6 +111,23 @@ loanPeriodicPayment( principalOutstanding, periodicRate, paymentsRemaining); } +Number +loanPrincipalFromPeriodicPayment( + Number const& periodicPayment, + Number const& periodicRate, + std::uint32_t paymentsRemaining) +{ + if (periodicRate == 0) + return periodicPayment * paymentsRemaining; + + /* + * This formula is the reverse of the one from the XLS-66 spec, + * section 3.2.4.1.1 (Regular Payment) used in loanPeriodicPayment + */ + return periodicPayment / + computePaymentFactor(periodicRate, paymentsRemaining); +} + Number loanLatePaymentInterest( Number const& principalOutstanding, diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index 801590ca28..b9df2147ff 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -2485,7 +2485,6 @@ ValidLoan::finalize( &sfLatePaymentFee, &sfClosePaymentFee, &sfPrincipalOutstanding, - &sfReferencePrincipal, &sfTotalValueOutstanding, &sfInterestOwed}) { diff --git a/src/xrpld/app/tx/detail/LoanManage.cpp b/src/xrpld/app/tx/detail/LoanManage.cpp index 87db5d3c00..df866b1237 100644 --- a/src/xrpld/app/tx/detail/LoanManage.cpp +++ b/src/xrpld/app/tx/detail/LoanManage.cpp @@ -271,9 +271,9 @@ LoanManage::defaultLoan( loanSle->setFlag(lsfLoanDefault); loanSle->at(sfTotalValueOutstanding) = 0; loanSle->at(sfPaymentRemaining) = 0; - loanSle->at(sfReferencePrincipal) = 0; principalOutstandingProxy = 0; interestOwedProxy = 0; + loanSle->at(~sfNextPaymentDueDate) = std::nullopt; view.update(loanSle); // Return funds from the LoanBroker pseudo-account to the diff --git a/src/xrpld/app/tx/detail/LoanPay.cpp b/src/xrpld/app/tx/detail/LoanPay.cpp index e9c85af255..5142b219eb 100644 --- a/src/xrpld/app/tx/detail/LoanPay.cpp +++ b/src/xrpld/app/tx/detail/LoanPay.cpp @@ -90,20 +90,19 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx) // This is definitely paying fewer than paymentsPerFeeIncrement payments return normalCost; - // This computation isn't free, but it's relatively straightforward if (auto const fullInterest = calculateFullPaymentInterest( asset, - loanSle->at(sfReferencePrincipal), + loanSle->at(sfPeriodicPayment), loanPeriodicRate( TenthBips32(loanSle->at(sfInterestRate)), loanSle->at(sfPaymentInterval)), + loanSle->at(sfPaymentRemaining), view.parentCloseTime(), loanSle->at(sfPaymentInterval), loanSle->at(sfPreviousPaymentDate), loanSle->at(sfStartDate), TenthBips32(loanSle->at(sfCloseInterestRate)), - scale, - loanSle->at(sfClosePaymentFee)); + scale); amount > loanSle->at(sfPrincipalOutstanding) + fullInterest + loanSle->at(sfClosePaymentFee)) return normalCost; @@ -189,18 +188,6 @@ LoanPay::preclaim(PreclaimContext const& ctx) JLOG(ctx.j.warn()) << "Borrower account is frozen."; return ret; } - if (auto const ret = checkDeepFrozen(ctx.view, brokerPseudoAccount, asset)) - { - JLOG(ctx.j.warn()) << "Loan Broker pseudo-account can not receive " - "funds (deep frozen)."; - return ret; - } - if (auto const ret = checkDeepFrozen(ctx.view, brokerOwner, asset)) - { - JLOG(ctx.j.warn()) - << "Loan Broker can not receive funds (deep frozen)."; - return ret; - } if (auto const ret = checkDeepFrozen(ctx.view, vaultPseudoAccount, asset)) { JLOG(ctx.j.warn()) @@ -223,6 +210,7 @@ LoanPay::doApply() auto const loanSle = view.peek(keylet::loan(loanID)); if (!loanSle) return tefBAD_LEDGER; // LCOV_EXCL_LINE + std::int32_t const loanScale = loanSle->at(sfLoanScale); auto const brokerID = loanSle->at(sfLoanBrokerID); auto const brokerSle = view.peek(keylet::loanbroker(brokerID)); @@ -237,6 +225,41 @@ LoanPay::doApply() auto const vaultPseudoAccount = vaultSle->at(sfAccount); auto const asset = *vaultSle->at(sfAsset); + // Determine where to send the broker's fee + auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); + TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; + auto debtTotalProxy = brokerSle->at(sfDebtTotal); + + // Send the broker fee to the owner if they have sufficient cover available, + // _and_ if the owner can receive funds. If not, so as not to block the + // payment, add it to the cover balance (send it to the broker pseudo + // account). + // + // Normally freeze status is checked in preflight, but we do it here to + // avoid duplicating the check. It'll claim a fee either way. + bool const sendBrokerFeeToOwner = coverAvailableProxy >= + roundToAsset(asset, + tenthBipsOfValue( + debtTotalProxy.value(), coverRateMinimum), + loanScale) && + !isDeepFrozen(view, brokerOwner, asset); + + auto const brokerPayee = + sendBrokerFeeToOwner ? brokerOwner : brokerPseudoAccount; + auto const brokerPayeeSle = view.peek(keylet::account(brokerPayee)); + if (!sendBrokerFeeToOwner) + { + // If we can't send the fee to the owner, and the pseudo-account is + // frozen, then we have to fail the payment. + if (auto const ret = checkDeepFrozen(view, brokerPayee, asset)) + { + JLOG(j_.warn()) + << "Both Loan Broker and Loan Broker pseudo-account " + "can not receive funds (deep frozen)."; + return ret; + } + } + //------------------------------------------------------ // Loan object state changes @@ -303,8 +326,6 @@ LoanPay::doApply() // LCOV_EXCL_STOP } - std::int32_t const loanScale = loanSle->at(sfLoanScale); - //------------------------------------------------------ // LoanBroker object state changes view.update(brokerSle); @@ -360,8 +381,6 @@ LoanPay::doApply() "ripple::LoanPay::doApply", "payments add up"); - auto debtTotalProxy = brokerSle->at(sfDebtTotal); - // Decrease LoanBroker Debt by the amount paid, add the Loan value change // (which might be negative). totalPaidToVaultForDebt may be negative, // increasing the debt @@ -387,7 +406,7 @@ LoanPay::doApply() vaultSle->at(sfAssetsAvailable) += totalPaidToVault; vaultSle->at(sfAssetsTotal) += interestPaidExtra; - interestOwedProxy -= interestPaidToVault; + interestOwedProxy -= interestPaidForDebt; XRPL_ASSERT_PARTS( *vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal), "ripple::LoanPay::doApply", @@ -398,27 +417,17 @@ LoanPay::doApply() // auto const unavailable = total - available; // Move funds - STAmount const paidToVault(asset, totalPaidToVault); - STAmount const paidToBroker(asset, totalPaidToBroker); XRPL_ASSERT_PARTS( - paidToVault + paidToBroker <= amount, + totalPaidToVault + totalPaidToBroker <= amount, "ripple::LoanPay::doApply", "amount is sufficient"); XRPL_ASSERT_PARTS( - paidToVault + paidToBroker <= paymentParts->principalPaid + + totalPaidToVault + totalPaidToBroker <= paymentParts->principalPaid + paymentParts->interestPaid + paymentParts->feeToPay, "ripple::LoanPay::doApply", "payment agreement"); - // Determine where to send the broker's fee - auto coverAvailableProxy = brokerSle->at(sfCoverAvailable); - TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; - - bool const sufficientCover = coverAvailableProxy >= - roundToAsset(asset, - tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), - loanScale); - if (!sufficientCover) + if (!sendBrokerFeeToOwner) { // If there is not enough first-loss capital, add the fee to First Loss // Cover Pool. Note that this moves the entire fee - it does not attempt @@ -426,8 +435,6 @@ LoanPay::doApply() // it for future needs. coverAvailableProxy += totalPaidToBroker; } - auto const brokerPayee = - sufficientCover ? brokerOwner : brokerPseudoAccount; #if !NDEBUG auto const accountBalanceBefore = @@ -447,19 +454,40 @@ LoanPay::doApply() view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); #endif - if (auto const ter = accountSend( + if (totalPaidToVault != Number{}) + { + if (auto const ter = requireAuth( + view, asset, vaultPseudoAccount, AuthType::StrongAuth)) + return ter; + } + + if (totalPaidToBroker != Number{}) + { + if (brokerPayee == account_) + { + // The broker may have deleted their holding. Recreate it if needed + if (auto const ter = addEmptyHolding( + view, + brokerPayee, + brokerPayeeSle->at(sfBalance).value().xrp(), + asset, + j_); + ter && ter != tecDUPLICATE) + // ignore tecDUPLICATE. That means the holding already exists, + // and is fine here + return ter; + } + if (auto const ter = + requireAuth(view, asset, brokerPayee, AuthType::StrongAuth)) + return ter; + } + + if (auto const ter = accountSendMulti( view, account_, - vaultPseudoAccount, - paidToVault, - j_, - WaiveTransferFee::Yes)) - return ter; - if (auto const ter = accountSend( - view, - account_, - brokerPayee, - paidToBroker, + asset, + {{vaultPseudoAccount, totalPaidToVault}, + {brokerPayee, totalPaidToBroker}}, j_, WaiveTransferFee::Yes)) return ter; @@ -481,6 +509,7 @@ LoanPay::doApply() : accountHolds( view, brokerPayee, asset, fhIGNORE_FREEZE, ahIGNORE_AUTH, j_); + /* auto const balanceScale = std::max( {accountBalanceBefore.exponent(), vaultBalanceBefore.exponent(), @@ -488,15 +517,10 @@ LoanPay::doApply() accountBalanceAfter.exponent(), vaultBalanceAfter.exponent(), brokerBalanceAfter.exponent()}); + */ XRPL_ASSERT_PARTS( - roundToAsset( - asset, - accountBalanceBefore + vaultBalanceBefore + brokerBalanceBefore, - balanceScale) == - roundToAsset( - asset, - accountBalanceAfter + vaultBalanceAfter + brokerBalanceAfter, - balanceScale), + accountBalanceBefore + vaultBalanceBefore + brokerBalanceBefore == + accountBalanceAfter + vaultBalanceAfter + brokerBalanceAfter, "ripple::LoanPay::doApply", "funds are conserved (with rounding)"); #endif diff --git a/src/xrpld/app/tx/detail/LoanSet.cpp b/src/xrpld/app/tx/detail/LoanSet.cpp index ee3cb507ab..5f8800caeb 100644 --- a/src/xrpld/app/tx/detail/LoanSet.cpp +++ b/src/xrpld/app/tx/detail/LoanSet.cpp @@ -79,12 +79,13 @@ LoanSet::preflight(PreflightContext const& ctx) {&sfLoanOriginationFee, &sfLoanServiceFee, &sfLatePaymentFee, - &sfClosePaymentFee, - &sfPrincipalRequested}) + &sfClosePaymentFee}) { if (!validNumericMinimum(tx[~*field])) return temINVALID; } + if (auto const p = tx[~sfPrincipalRequested]; p && p <= 0) + return temINVALID; if (!validNumericRange(tx[~sfInterestRate], maxInterestRate)) return temINVALID; if (!validNumericRange(tx[~sfOverpaymentFee], maxOverpaymentFee)) @@ -304,22 +305,14 @@ LoanSet::doApply() auto const properties = computeLoanProperties( vaultAsset, principalRequested, - principalRequested, interestRate, paymentInterval, paymentTotal, TenthBips32{brokerSle->at(sfManagementFeeRate)}); - if (properties.firstPaymentPrincipal <= 0) - { - // 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 tecPRECISION_LOSS; - } - if (interestRate != 0 && + // Guard 1: if there is no computed total interest over the life of the loan + // for a non-zero interest rate, we cannot properly amortize the loan + if (interestRate > TenthBips32{0} && (properties.totalValueOutstanding - principalRequested) <= 0) { // Unless this is a zero-interst loan, there must be some interest due @@ -328,6 +321,39 @@ LoanSet::doApply() << "% interest has no interest due"; return tecPRECISION_LOSS; } + + // Guard 2: if the rounded periodic payment is large enough that the loan + // can't be amortized in the specified number of payments, raise an error + if (auto const computedPayments = roundToAsset( + vaultAsset, + properties.totalValueOutstanding / + roundToAsset( + vaultAsset, + properties.periodicPayment, + properties.loanScale, + Number::upward), + properties.loanScale, + Number::upward); + computedPayments < paymentTotal) + { + JLOG(j_.warn()) << "Loan Periodic payment rounding will complete the " + "loan in less than the specified number of payments"; + return tecPRECISION_LOSS; + } + + // Guard 3: if the principal portion of the first periodic payment is too + // small to be accurately represented with the given rounding mode, raise an + // error + if (properties.firstPaymentPrincipal <= 0) + { + // 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 tecPRECISION_LOSS; + } + // Check that the other computed values are valid if (properties.interestOwedToVault < 0 || properties.totalValueOutstanding <= 0 || @@ -398,51 +424,57 @@ LoanSet::doApply() // 1. Transfer loanAssetsAvailable (principalRequested - originationFee) // from vault pseudo-account to the borrower. // Create a holding for the borrower if one does not already exist. - if (auto const ter = addEmptyHolding( - view, - borrower, - borrowerSle->at(sfBalance).value().xrp(), - vaultAsset, - j_); - ter && ter != tecDUPLICATE) - // ignore tecDUPLICATE. That means the holding already exists, and - // is fine here + + if (borrower == account_) + { + if (auto const ter = addEmptyHolding( + view, + borrower, + borrowerSle->at(sfBalance).value().xrp(), + vaultAsset, + j_); + ter && ter != tecDUPLICATE) + // ignore tecDUPLICATE. That means the holding already exists, and + // is fine here + return ter; + } + if (auto const ter = + requireAuth(view, vaultAsset, borrower, AuthType::StrongAuth)) return ter; - if (auto const ter = accountSend( - view, - vaultPseudo, - borrower, - STAmount{vaultAsset, loanAssetsToBorrower}, - j_, - WaiveTransferFee::Yes)) - return ter; // 2. Transfer originationFee, if any, from vault pseudo-account to // LoanBroker owner. if (originationFee != Number{}) { // Create the holding if it doesn't already exist (necessary for MPTs). // The owner may have deleted their MPT / line at some point. - if (auto const ter = addEmptyHolding( - view, - brokerOwner, - brokerOwnerSle->at(sfBalance).value().xrp(), - vaultAsset, - j_); - !isTesSuccess(ter) && ter != tecDUPLICATE) - // ignore tecDUPLICATE. That means the holding already exists, and - // is fine here - return ter; - if (auto const ter = accountSend( - view, - vaultPseudo, - brokerOwner, - STAmount{vaultAsset, originationFee}, - j_, - WaiveTransferFee::Yes)) + if (brokerOwner == account_) + { + if (auto const ter = addEmptyHolding( + view, + brokerOwner, + brokerOwnerSle->at(sfBalance).value().xrp(), + vaultAsset, + j_); + !isTesSuccess(ter) && ter != tecDUPLICATE) + // ignore tecDUPLICATE. That means the holding already exists, + // and is fine here + return ter; + } + if (auto const ter = requireAuth( + view, vaultAsset, brokerOwner, AuthType::StrongAuth)) return ter; } + if (auto const ter = accountSendMulti( + view, + vaultPseudo, + vaultAsset, + {{borrower, loanAssetsToBorrower}, {brokerOwner, originationFee}}, + j_, + WaiveTransferFee::Yes)) + return ter; + // The portion of the loan interest that will go to the vault (total // interest minus the management fee) auto const startDate = view.info().closeTime.time_since_epoch().count(); @@ -461,7 +493,7 @@ LoanSet::doApply() }; // Set required and fixed tx fields - loan->at(sfLoanScale) = principalRequested.exponent(); + loan->at(sfLoanScale) = properties.loanScale; loan->at(sfStartDate) = startDate; loan->at(sfPaymentInterval) = paymentInterval; loan->at(sfLoanSequence) = *loanSequenceProxy; @@ -482,7 +514,6 @@ LoanSet::doApply() setLoanField(~sfGracePeriod, defaultGracePeriod); // Set dynamic / computed fields to their initial values loan->at(sfPrincipalOutstanding) = principalRequested; - loan->at(sfReferencePrincipal) = principalRequested; loan->at(sfPeriodicPayment) = properties.periodicPayment; loan->at(sfTotalValueOutstanding) = properties.totalValueOutstanding; loan->at(sfInterestOwed) = properties.interestOwedToVault;