Update payment component computation and rounding, and more tests

- Tests not expected to pass.
- Check in LoanSet if a loan with interest actually has interest.
  tecPRECISION_LOSS if not.
- Add checks in LoanPay for deep froze broker owner and pseudoaccount.
- Fix management fee calculations in LoanPay and associated LoanBroker
  and Vault data updates.
- Make state tracking next payment due date optional.
- Add a test case showing multiple payments combined.
- Update more tests to work with the new fields.
This commit is contained in:
Ed Hennis
2025-10-07 21:48:43 -04:00
parent fc2163708a
commit 2dd239c59f
5 changed files with 406 additions and 133 deletions

View File

@@ -110,7 +110,7 @@ class Loan_test : public beast::unit_test::suite
{
std::uint32_t previousPaymentDate = 0;
NetClock::time_point startDate = {};
std::uint32_t nextPaymentDate = 0;
std::optional<std::uint32_t> nextPaymentDate = 0;
std::uint32_t paymentRemaining = 0;
std::int32_t const loanScale = 0;
Number totalValue = 0;
@@ -210,7 +210,7 @@ class Loan_test : public beast::unit_test::suite
void
operator()(
std::uint32_t previousPaymentDate,
std::uint32_t nextPaymentDate,
std::optional<std::uint32_t> nextPaymentDate,
std::uint32_t paymentRemaining,
Number const& loanScale,
Number const& totalValue,
@@ -226,7 +226,7 @@ class Loan_test : public beast::unit_test::suite
env.test.BEAST_EXPECT(
loan->at(sfPreviousPaymentDate) == previousPaymentDate);
env.test.BEAST_EXPECT(
loan->at(sfNextPaymentDueDate) == nextPaymentDate);
loan->at(~sfNextPaymentDueDate) == nextPaymentDate);
env.test.BEAST_EXPECT(
loan->at(sfPaymentRemaining) == paymentRemaining);
env.test.BEAST_EXPECT(loan->at(sfLoanScale) == loanScale);
@@ -360,7 +360,7 @@ 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),
@@ -373,8 +373,9 @@ class Loan_test : public beast::unit_test::suite
.interestRate = TenthBips32{loan->at(sfInterestRate)},
};
BEAST_EXPECT(state.previousPaymentDate == 0);
BEAST_EXPECT(
tp{d{state.nextPaymentDate}} == state.startDate + 600s);
if (BEAST_EXPECT(state.nextPaymentDate))
BEAST_EXPECT(
tp{d{*state.nextPaymentDate}} == state.startDate + 600s);
BEAST_EXPECT(state.paymentRemaining == 12);
BEAST_EXPECT(
state.principalOutstanding == broker.asset(1000).value());
@@ -1231,7 +1232,9 @@ class Loan_test : public beast::unit_test::suite
verifyLoanStatus(state);
}
auto const nextDueDate = tp{d{state.nextPaymentDate}};
BEAST_EXPECT(state.nextPaymentDate);
auto const nextDueDate =
tp{d{state.nextPaymentDate.value_or(0)}};
// Can't default the loan yet. The grace period hasn't
// expired
@@ -1273,7 +1276,6 @@ class Loan_test : public beast::unit_test::suite
// Can't make a payment on it either
env(pay(borrower, loanKeylet.key, broker.asset(300)),
ter(tecKILLED));
};
};
@@ -1354,6 +1356,11 @@ class Loan_test : public beast::unit_test::suite
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
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);
@@ -1373,6 +1380,120 @@ class Loan_test : public beast::unit_test::suite
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));
};
};
auto multiplePayoff = [&](std::uint32_t baseFlag) {
return [&, baseFlag](
Keylet const& loanKeylet,
VerifyLoanStatus const& verifyLoanStatus) {
// toEndOfLife
//
auto 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
auto const startingPayments = state.paymentRemaining;
auto const rawPayoff = startingPayments *
(state.periodicPayment + broker.asset(2).value());
STAmount const payoffAmount{broker.asset, rawPayoff};
BEAST_EXPECT(
payoffAmount ==
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{
@@ -1482,6 +1603,34 @@ class Loan_test : public beast::unit_test::suite
tfLoanOverpayment,
immediatePayoff(lsfLoanOverpayment));
lifecycle(
caseLabel,
"Loan overpayment prohibited - Make multiple payments",
env,
loanAmount,
interestExponent,
lender,
borrower,
evan,
broker,
pseudoAcct,
0,
multiplePayoff(0));
lifecycle(
caseLabel,
"Loan overpayment allowed - Make multiple payments",
env,
loanAmount,
interestExponent,
lender,
borrower,
evan,
broker,
pseudoAcct,
tfLoanOverpayment,
multiplePayoff(lsfLoanOverpayment));
lifecycle(
caseLabel,
"Loan overpayment prohibited - Make payments",
@@ -1529,20 +1678,24 @@ class Loan_test : public beast::unit_test::suite
roundPeriodicPayment(
broker.asset, state.periodicPayment, state.loanScale)};
testcase << "\tPayment components: "
<< "Payments remaining, rawInterest, rawPrincipal, "
"roundedInterest, "
"roundedPrincipal, roundedPayment, final, extra";
while (state.paymentRemaining > 0)
{
testcase << "Payments remaining: " << state.paymentRemaining
<< ", computed payment amount: "
<< state.periodicPayment;
auto const serviceFee = broker.asset(2);
// Only check the first payment since the rounding
// may drift as payments are made
BEAST_EXPECT(
roundedPeriodicPayment ==
broker.asset(Number(8333457001162141, -14)));
broker.asset(
Number(8333457001162141, -14), Number::upward));
// 83334570.01162141
// Include the service fee
STAmount const totalDue = roundToScale(
roundedPeriodicPayment + broker.asset(2),
roundedPeriodicPayment + serviceFee,
state.loanScale,
Number::upward);
// Only check the first payment since the rounding
@@ -1569,38 +1722,52 @@ class Loan_test : public beast::unit_test::suite
state.loanScale,
Number::upward));
auto const totalDueAmount =
STAmount{broker.asset, totalDue};
// Compute the expected principal amount
Number const rawInterest = state.paymentRemaining == 1
? state.periodicPayment - state.referencePrincipal
: state.referencePrincipal * periodicRate;
STAmount const interest{
broker.asset,
roundToAsset(
auto const paymentComponents =
detail::computePaymentComponents(
broker.asset,
rawInterest,
state.loanScale,
Number::upward)};
state.totalValue,
state.principalOutstanding,
state.referencePrincipal,
state.periodicPayment,
periodicRate,
state.paymentRemaining);
testcase
<< "\tPayment components: " << state.paymentRemaining
<< ", " << paymentComponents.rawInterest << ", "
<< paymentComponents.rawPrincipal << ", "
<< paymentComponents.roundedInterest << ", "
<< paymentComponents.roundedPrincipal << ", "
<< paymentComponents.roundedPayment << ", "
<< paymentComponents.final << ", "
<< paymentComponents.extra;
auto const totalDueAmount = STAmount{
broker.asset,
paymentComponents.roundedPayment + serviceFee.number()};
BEAST_EXPECT(
paymentComponents.final || totalDue == totalDueAmount);
BEAST_EXPECT(
state.paymentRemaining < 12 ||
interest ==
paymentComponents.roundedInterest ==
roundToScale(
broker.asset(
Number(2283105022831050, -18),
Number::upward),
state.loanScale,
Number::upward));
BEAST_EXPECT(interest >= Number(0));
BEAST_EXPECT(
paymentComponents.roundedInterest >= Number(0));
auto const rawPrincipal =
state.periodicPayment - rawInterest;
BEAST_EXPECT(
state.paymentRemaining < 12 ||
roundToAsset(
broker.asset,
rawPrincipal,
paymentComponents.rawPrincipal,
state.loanScale,
Number::upward) ==
roundToScale(
@@ -1610,25 +1777,27 @@ class Loan_test : public beast::unit_test::suite
state.loanScale,
Number::upward));
BEAST_EXPECT(
state.paymentRemaining > 1 ||
rawPrincipal == state.principalOutstanding);
STAmount const principal{
broker.asset,
roundToAsset(
broker.asset,
roundedPeriodicPayment - interest,
state.loanScale,
Number::downward)};
!paymentComponents.final ||
paymentComponents.rawPrincipal ==
state.referencePrincipal);
BEAST_EXPECT(
principal > Number(0) &&
principal <= state.principalOutstanding);
paymentComponents.roundedPrincipal >= Number(0) &&
paymentComponents.roundedPrincipal <=
state.principalOutstanding);
BEAST_EXPECT(
state.paymentRemaining > 1 ||
principal == state.principalOutstanding);
!paymentComponents.final ||
paymentComponents.roundedPrincipal ==
state.principalOutstanding);
BEAST_EXPECT(
rawPrincipal + rawInterest == state.periodicPayment);
paymentComponents.final ||
paymentComponents.rawPrincipal +
paymentComponents.rawInterest ==
state.periodicPayment);
BEAST_EXPECT(
principal + interest == roundedPeriodicPayment);
paymentComponents.final ||
paymentComponents.roundedPrincipal +
paymentComponents.roundedInterest ==
roundedPeriodicPayment);
auto const borrowerBalanceBeforePayment =
env.balance(borrower, broker.asset);
@@ -1665,9 +1834,29 @@ class Loan_test : public beast::unit_test::suite
Number(1, -4))));
--state.paymentRemaining;
state.previousPaymentDate = state.nextPaymentDate;
state.nextPaymentDate += state.paymentInterval;
state.principalOutstanding -= principal;
if (BEAST_EXPECT(state.nextPaymentDate))
{
state.previousPaymentDate = *state.nextPaymentDate;
if (paymentComponents.final)
{
state.nextPaymentDate.reset();
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(
broker.asset.raw(),
paymentComponents.roundedInterest,
managementFeeRateParameter,
state.loanScale);
verifyLoanStatus(state);
}
@@ -1725,7 +1914,8 @@ class Loan_test : public beast::unit_test::suite
MPTTester mptt{env, issuer, mptInitNoFund};
mptt.create(
{.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const mptAsset = mptt.issuanceID();
// Scale the MPT asset a little bit so we can get some interest
PrettyAsset const mptAsset{mptt.issuanceID(), 100};
mptt.authorize({.account = lender});
mptt.authorize({.account = borrower});
mptt.authorize({.account = evan});

View File

@@ -150,14 +150,13 @@ computePaymentComponents(
"Outstanding values are rounded");
auto const roundedPeriodicPayment =
roundPeriodicPayment(asset, periodicPayment, scale);
if (paymentRemaining == 1 || totalValueOutstanding <= periodicPayment)
if (paymentRemaining == 1 ||
totalValueOutstanding <= roundedPeriodicPayment)
{
// If there's only one payment left, we need to pay off each of the loan
// parts.
//
// The totalValueOutstanding should never be less than the
// periodicPayment until the last scheduled payment, but if it ever is,
// make it the last payment.
// 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;
@@ -169,7 +168,7 @@ computePaymentComponents(
"last payment is complete");
return {
.rawInterest = rawInterest,
.rawInterest = std::max(Number{}, rawInterest),
.rawPrincipal = referencePrincipal,
.roundedInterest = roundedInterest,
.roundedPrincipal = principalOutstanding,
@@ -190,54 +189,54 @@ computePaymentComponents(
"ripple::detail::computePaymentComponents",
"valid raw interest");
XRPL_ASSERT_PARTS(
rawPrincipal > 0 && rawPrincipal <= referencePrincipal,
rawPrincipal >= 0 && rawPrincipal <= referencePrincipal,
"ripple::detail::computePaymentComponents",
"valid raw principal");
// if (count($A20), MIN(Z19, Z19 - FLOOR(AA19 - Y20, 1)), "")
// Z19 = outstanding principal
// AA19 = reference principal
// Y20 = raw principal
Number const roundedPrincipal = [&]() {
Number const p = std::max(
Number{},
std::min(
principalOutstanding,
principalOutstanding -
roundToAsset(
asset,
referencePrincipal - rawPrincipal,
scale,
Number::downward)));
// if the estimated principal payment would leave the principal higher
// than the "total "after payment" value of the loan, make the principal
// payment also take the principal down to that same "after" value.
// This should mean that all interest is paid, or that the loan has some
// tricky parameters.
if (principalOutstanding - p >
totalValueOutstanding - roundedPeriodicPayment)
return roundedPeriodicPayment;
// Use the amount that will get principal outstanding as close to
// reference principal as possible, but don't pay more than the rounded
// periodic payment, or we'll end up with negative interest.
return std::min(p, roundedPeriodicPayment);
// 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;
XRPL_ASSERT_PARTS(
p >= 0 && 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);
else if (p < 0)
return Number{};
return p;
}();
// if(count($A20), if(AB19 < $B$5, AB19 - Z19, CEILING($B$10-W20, 1)), "")
// AB19 = total loan value
// $B$5 = periodic payment (unrounded)
// Z19 = outstanding principal
// $B$10 = periodic payment (rounded up)
// W20 = rounded principal
Number const roundedInterest = [&]() {
// Zero interest means ZERO interest
if (periodicRate == 0)
return Number{};
auto i = roundedPeriodicPayment - roundedPrincipal;
// No negative interest!
if (i < 0)
return Number{};
return i;
}();
Number const roundedInterest = roundedPeriodicPayment - roundedPrincipal;
XRPL_ASSERT_PARTS(
roundedInterest >= 0 && isRounded(asset, roundedInterest, scale),
"ripple::detail::computePaymentComponents",
"valid rounded interest");
XRPL_ASSERT_PARTS(
roundedPrincipal >= 0 && roundedPrincipal <= principalOutstanding &&
roundedPrincipal <= roundedPeriodicPayment &&
isRounded(asset, roundedPrincipal, scale),
"ripple::detail::computePaymentComponents",
"valid rounded principal");
@@ -280,11 +279,12 @@ doPayment(
nextDueDateProxy,
"ripple::detail::doPayment",
"Next due date proxy set");
auto const totalValueDelta = payment.roundedPrincipal +
payment.roundedInterest - payment.valueChange;
if (!payment.extra)
{
if (payment.final)
{
paymentRemainingProxy = 0;
XRPL_ASSERT_PARTS(
referencePrincipalProxy == payment.rawPrincipal,
"ripple::detail::doPayment",
@@ -294,11 +294,12 @@ doPayment(
"ripple::detail::doPayment",
"Full principal payment");
XRPL_ASSERT_PARTS(
totalValueOutstandingProxy ==
payment.roundedPrincipal + payment.roundedInterest,
totalValueOutstandingProxy == totalValueDelta,
"ripple::detail::doPayment",
"Full value payment");
paymentRemainingProxy = 0;
prevPaymentDateProxy = *nextDueDateProxy;
// Remove the field. This is the only condition where nextDueDate is
// allowed to be removed.
@@ -306,6 +307,19 @@ doPayment(
}
else
{
XRPL_ASSERT_PARTS(
referencePrincipalProxy > payment.rawPrincipal,
"ripple::detail::doPayment",
"Full reference principal payment");
XRPL_ASSERT_PARTS(
principalOutstandingProxy > payment.roundedPrincipal,
"ripple::detail::doPayment",
"Full principal payment");
XRPL_ASSERT_PARTS(
totalValueOutstandingProxy > totalValueDelta,
"ripple::detail::doPayment",
"Full value payment");
paymentRemainingProxy -= 1;
prevPaymentDateProxy = *nextDueDateProxy;
@@ -317,8 +331,7 @@ doPayment(
referencePrincipalProxy -= payment.rawPrincipal;
principalOutstandingProxy -= payment.roundedPrincipal;
totalValueOutstandingProxy -=
payment.roundedPrincipal + payment.roundedInterest;
totalValueOutstandingProxy -= totalValueDelta;
return LoanPaymentParts{
.principalPaid = payment.roundedPrincipal,
@@ -1008,10 +1021,15 @@ loanMakePayment(
nextDueDateProxy,
paymentInterval);
++numPayments;
if (nextPayment.final)
break;
}
XRPL_ASSERT_PARTS(
totalParts.principalPaid + totalParts.interestPaid == totalPaid,
totalParts.principalPaid + totalParts.interestPaid +
totalParts.feeToPay ==
totalPaid,
"ripple::loanMakePayment",
"payment parts add up");
XRPL_ASSERT_PARTS(
@@ -1025,17 +1043,16 @@ loanMakePayment(
// -------------------------------------------------------------
// overpayment handling
if (loan->isFlag(lsfLoanOverpayment))
if (loan->isFlag(lsfLoanOverpayment) && paymentRemainingProxy > 0 &&
nextDueDateProxy && totalPaid < amount)
{
TenthBips32 const overpaymentInterestRate{
loan->at(sfOverpaymentInterestRate)};
TenthBips32 const overpaymentFeeRate{loan->at(sfOverpaymentFee)};
Number const overpayment = amount -
(totalParts.principalPaid + totalParts.interestPaid +
totalParts.feeToPay);
Number const overpayment = amount - totalPaid;
XRPL_ASSERT(
overpayment >= 0 && isRounded(asset, overpayment, loanScale),
overpayment > 0 && isRounded(asset, overpayment, loanScale),
"ripple::loanMakePayment : valid overpayment amount");
Number const fee = roundToAsset(

View File

@@ -61,13 +61,11 @@ loanPeriodicPayment(
/*
* This formula is from the XLS-66 spec, section 3.2.4.1.1 (Regular
* Payment), though the awkwardly-named "timeFactor" is computed only once
* and used twice.
* Payment), though "raisedRate" is computed only once and used twice.
*/
// TODO: Need a better name
Number const timeFactor = power(1 + periodicRate, paymentsRemaining);
Number const raisedRate = power(1 + periodicRate, paymentsRemaining);
return principalOutstanding * periodicRate * timeFactor / (timeFactor - 1);
return principalOutstanding * periodicRate * raisedRate / (raisedRate - 1);
}
Number

View File

@@ -48,7 +48,7 @@ LoanPay::calculateBaseFee(ReadView const& view, STTx const& tx)
auto const normalCost = Transactor::calculateBaseFee(view, tx);
auto const paymentsPerFeeIncrement = 20;
// The fee is based on the number of potential payments, unless the loan is
// The fee is based on the potential number of payments, unless the loan is
// being fully paid off.
auto const amount = tx[sfAmount];
auto const loanID = tx[sfLoanID];
@@ -136,17 +136,17 @@ LoanPay::preclaim(PreclaimContext const& ctx)
return tecNO_ENTRY;
}
auto const principalOutstanding = loanSle->at(sfPrincipalOutstanding);
TenthBips32 const interestRate{loanSle->at(sfInterestRate)};
auto const paymentRemaining = loanSle->at(sfPaymentRemaining);
TenthBips32 const lateInterestRate{loanSle->at(sfLateInterestRate)};
if (loanSle->at(sfBorrower) != account)
{
JLOG(ctx.j.warn()) << "Loan does not belong to the account.";
return tecNO_PERMISSION;
}
auto const principalOutstanding = loanSle->at(sfPrincipalOutstanding);
TenthBips32 const interestRate{loanSle->at(sfInterestRate)};
auto const paymentRemaining = loanSle->at(sfPaymentRemaining);
TenthBips32 const lateInterestRate{loanSle->at(sfLateInterestRate)};
if (paymentRemaining == 0 || principalOutstanding == 0)
{
JLOG(ctx.j.warn()) << "Loan is already paid off.";
@@ -164,6 +164,7 @@ LoanPay::preclaim(PreclaimContext const& ctx)
// LCOV_EXCL_STOP
}
auto const brokerPseudoAccount = loanBrokerSle->at(sfAccount);
auto const brokerOwner = loanBrokerSle->at(sfOwner);
auto const vaultID = loanBrokerSle->at(sfVaultID);
auto const vaultSle = ctx.view.read(keylet::vault(vaultID));
if (!vaultSle)
@@ -175,6 +176,7 @@ LoanPay::preclaim(PreclaimContext const& ctx)
// LCOV_EXCL_STOP
}
auto const asset = vaultSle->at(sfAsset);
auto const vaultPseudoAccount = vaultSle->at(sfAccount);
if (amount.asset() != asset)
{
@@ -193,6 +195,18 @@ LoanPay::preclaim(PreclaimContext const& ctx)
"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())
<< "Vault pseudo-account can not receive funds (deep frozen).";
return ret;
}
return tesSUCCESS;
}
@@ -235,12 +249,27 @@ LoanPay::doApply()
}
TenthBips16 managementFeeRate{brokerSle->at(sfManagementFeeRate)};
auto const managementFeeOutstanding = [&]() {
auto const m = loanSle->at(sfTotalValueOutstanding) -
loanSle->at(sfPrincipalOutstanding) - loanSle->at(sfInterestOwed);
// It shouldn't be possible for this to result in a negative number, but
// with overpayments, who knows?
if (m < 0)
return Number{};
return m;
}();
Expected<LoanPaymentParts, TER> paymentParts =
loanMakePayment(asset, view, loanSle, amount, managementFeeRate, j_);
if (!paymentParts)
{
XRPL_ASSERT_PARTS(
paymentParts.error(),
"ripple::LoanPay::doApply",
"payment error is an error");
return paymentParts.error();
}
// If the payment computation completed without error, the loanSle object
// has been modified.
@@ -256,6 +285,11 @@ LoanPay::doApply()
paymentParts->interestPaid >= 0,
"ripple::LoanPay::doApply",
"valid interest paid");
XRPL_ASSERT_PARTS(
// It should not be possible to pay 0 total
paymentParts->principalPaid + paymentParts->interestPaid > 0,
"ripple::LoanPay::doApply",
"valid principal paid");
XRPL_ASSERT_PARTS(
paymentParts->feeToPay >= 0,
"ripple::LoanPay::doApply",
@@ -277,27 +311,45 @@ LoanPay::doApply()
auto interestOwedProxy = loanSle->at(sfInterestOwed);
auto const [managementFee, interestPaidToVault] = [&]() {
auto const managementFee = roundToAsset(
auto const [managementFee, interestPaidForDebt, interestPaidExtra] = [&]() {
auto const interestOwed =
paymentParts->interestPaid - paymentParts->valueChange;
auto const interestPaidExtra = paymentParts->valueChange;
auto const managementFeeOwed = std::min(
managementFeeOutstanding,
roundToAsset(
asset,
tenthBipsOfValue(interestOwed, managementFeeRate),
loanScale));
auto const managementFeeExtra = roundToAsset(
asset,
tenthBipsOfValue(paymentParts->interestPaid, managementFeeRate),
tenthBipsOfValue(interestPaidExtra, managementFeeRate),
loanScale);
auto const interest = paymentParts->interestPaid - managementFee;
auto const interestForDebt = interestOwed - managementFeeOwed;
auto const interestExtra = interestPaidExtra - managementFeeExtra;
auto const owed = *interestOwedProxy;
if (interest > owed)
return std::make_pair(paymentParts->interestPaid - owed, owed);
return std::make_pair(managementFee, interest);
if (interestForDebt > owed)
return std::make_tuple(
interestOwed - owed + managementFeeExtra, owed, interestExtra);
return std::make_tuple(
managementFeeOwed + managementFeeExtra,
interestForDebt,
interestExtra);
}();
XRPL_ASSERT_PARTS(
managementFee >= 0 && interestPaidToVault >= 0 &&
(managementFee + interestPaidToVault ==
managementFee >= 0 && interestPaidForDebt >= 0 &&
interestPaidExtra >= 0 &&
(managementFee + interestPaidForDebt + interestPaidExtra ==
paymentParts->interestPaid) &&
isRounded(asset, managementFee, loanScale) &&
isRounded(asset, interestPaidToVault, loanScale),
isRounded(asset, interestPaidForDebt, loanScale) &&
isRounded(asset, interestPaidExtra, loanScale),
"ripple::LoanPay::doApply",
"management fee computation is valid");
auto const totalPaidToVault =
paymentParts->principalPaid + interestPaidToVault;
auto const totalPaidToVaultForDebt =
paymentParts->principalPaid + interestPaidForDebt;
auto const totalPaidToVault = totalPaidToVaultForDebt + interestPaidExtra;
auto const totalPaidToBroker = paymentParts->feeToPay + managementFee;
@@ -311,33 +363,40 @@ LoanPay::doApply()
auto debtTotalProxy = brokerSle->at(sfDebtTotal);
// Decrease LoanBroker Debt by the amount paid, add the Loan value change
// (which might be negative). debtDecrease may be negative, increasing the
// debt
auto const debtDecrease = totalPaidToVault - paymentParts->valueChange;
// (which might be negative). totalPaidToVaultForDebt may be negative,
// increasing the debt
XRPL_ASSERT_PARTS(
isRounded(asset, debtDecrease, loanScale),
isRounded(asset, totalPaidToVaultForDebt, loanScale),
"ripple::LoanPay::doApply",
"debtDecrease rounding good");
"totalPaidToVaultForDebt rounding good");
// Despite our best efforts, it's possible for rounding errors to accumulate
// in the loan broker's debt total. This is because the broker may have more
// that one loan with significantly different scales.
if (debtDecrease >= debtTotalProxy)
if (totalPaidToVaultForDebt >= debtTotalProxy)
debtTotalProxy = 0;
else
debtTotalProxy -= debtDecrease;
debtTotalProxy -= totalPaidToVaultForDebt;
//------------------------------------------------------
// Vault object state changes
view.update(vaultSle);
// auto const available = *vaultSle->at(sfAssetsAvailable);
// auto const total = *vaultSle->at(sfAssetsTotal);
// auto const unavailable = total - available;
vaultSle->at(sfAssetsAvailable) += totalPaidToVault;
vaultSle->at(sfAssetsTotal) += paymentParts->valueChange;
vaultSle->at(sfAssetsTotal) += interestPaidExtra;
interestOwedProxy -= interestPaidToVault;
XRPL_ASSERT_PARTS(
*vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal),
"ripple::LoanPay::doApply",
"assets available must not be greater than assets outstanding");
// auto const available = *vaultSle->at(sfAssetsAvailable);
// auto const total = *vaultSle->at(sfAssetsTotal);
// auto const unavailable = total - available;
// Move funds
STAmount const paidToVault(asset, totalPaidToVault);
STAmount const paidToBroker(asset, totalPaidToBroker);

View File

@@ -317,7 +317,16 @@ LoanSet::doApply()
// good. Note that the outstanding principal is rounded, and may not
// change right away.
JLOG(j_.warn()) << "Loan is unable to pay principal.";
return tecLIMIT_EXCEEDED;
return tecPRECISION_LOSS;
}
if (interestRate != 0 &&
(properties.totalValueOutstanding - principalRequested) <= 0)
{
// Unless this is a zero-interst loan, there must be some interest due
// on the loan, even if it's (measurable) dust
JLOG(j_.warn()) << "Loan with " << interestRate
<< "% interest has no interest due";
return tecPRECISION_LOSS;
}
// Check that the other computed values are valid
if (properties.interestOwedToVault < 0 ||