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:
Ed Hennis
2025-10-09 00:48:58 -04:00
parent 2dd239c59f
commit 1efc532b21
11 changed files with 1075 additions and 474 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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(

View File

@@ -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());
}
}

View File

@@ -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,

View File

@@ -2485,7 +2485,6 @@ ValidLoan::finalize(
&sfLatePaymentFee,
&sfClosePaymentFee,
&sfPrincipalOutstanding,
&sfReferencePrincipal,
&sfTotalValueOutstanding,
&sfInterestOwed})
{

View File

@@ -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

View File

@@ -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

View File

@@ -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;