mirror of
https://github.com/XRPLF/rippled.git
synced 2025-12-06 17:27:55 +00:00
Implement Vito's new loan payment part rounding algorithm, and more
- Implement AccountSendMulti - Document the derivations of loan components. - Add "loanPrincipalFromPeriodicPayment" helper. - Removed sfReferencePrincipal - LoanSet and LoanPay can create MPTokens as a side effect - LoanPay will send the fee to cover if the broker owner is deep frozen, and fail if both of them are deep frozen. - LoanPay will check auth for the receivers, or create holdings for the submitting account if needed. - LoanSet will fail if principal requested is not positive - Handle overpayment in a separate function - Add a test helper to check that balance changes went as expected - Fix more tests
This commit is contained in:
@@ -828,6 +828,22 @@ accountSend(
|
||||
beast::Journal j,
|
||||
WaiveTransferFee waiveFee = WaiveTransferFee::No);
|
||||
|
||||
using MultiplePaymentDestinations = std::vector<std::pair<AccountID, Number>>;
|
||||
/** 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<MPTIssue>())
|
||||
{
|
||||
return tecINTERNAL;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
XRPL_ASSERT(
|
||||
!asset.holds<MPTIssue>(), "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<MPTIssue>().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<MPTIssue>().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<MPTIssue>(), "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(
|
||||
[&]<ValidIssueType TIss>(TIss const& issue) {
|
||||
if constexpr (std::is_same_v<TIss, Issue>)
|
||||
return accountSendMultiIOU(
|
||||
view, senderID, asset, receivers, j, waiveFee);
|
||||
else
|
||||
return accountSendMultiMPT(
|
||||
view, senderID, asset, receivers, j, waiveFee);
|
||||
},
|
||||
asset.value());
|
||||
}
|
||||
|
||||
static bool
|
||||
updateTrustLine(
|
||||
ApplyView& view,
|
||||
|
||||
@@ -110,12 +110,11 @@ class Loan_test : public beast::unit_test::suite
|
||||
{
|
||||
std::uint32_t previousPaymentDate = 0;
|
||||
NetClock::time_point startDate = {};
|
||||
std::optional<std::uint32_t> 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<std::uint32_t> 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<Issue>() &&
|
||||
((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(
|
||||
|
||||
@@ -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 <class NumberProxy, class Int32Proxy, class Int32OptionalProxy>
|
||||
template <class NumberProxy, class UInt32Proxy, class UInt32OptionalProxy>
|
||||
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<Number> or Number
|
||||
static_cast<Number>(principalOutstandingProxy) <=
|
||||
static_cast<Number>(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<LoanPaymentParts, TER>
|
||||
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<std::uint32_t> 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 <AssetType A>
|
||||
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 <AssetType A>
|
||||
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 <AssetType A>
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2485,7 +2485,6 @@ ValidLoan::finalize(
|
||||
&sfLatePaymentFee,
|
||||
&sfClosePaymentFee,
|
||||
&sfPrincipalOutstanding,
|
||||
&sfReferencePrincipal,
|
||||
&sfTotalValueOutstanding,
|
||||
&sfInterestOwed})
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user