Convert LoanSet to precomputed all values

- Tests are not expected to pass
This commit is contained in:
Ed Hennis
2025-10-02 21:01:19 -04:00
parent 2509293943
commit e1939d0d20
6 changed files with 265 additions and 141 deletions

View File

@@ -561,11 +561,12 @@ LEDGER_ENTRY(ltLOAN, 0x0089, Loan, loan, ({
{sfPaymentInterval, soeREQUIRED},
{sfGracePeriod, soeREQUIRED},
{sfPeriodicPayment, soeREQUIRED},
{sfPreviousPaymentDate, soeREQUIRED},
{sfPreviousPaymentDate, soeDEFAULT},
{sfNextPaymentDueDate, soeREQUIRED},
{sfPaymentRemaining, soeREQUIRED},
{sfPrincipalOutstanding, soeREQUIRED},
{sfPaymentRemaining, soeDEFAULT},
{sfPrincipalOutstanding, soeDEFAULT},
{sfTotalValueOutstanding, soeDEFAULT},
{sfInterestOwed, soeDEFAULT},
// Based on the original principal borrowed, used for
// rounding calculated values so they are all on a
// consistent scale - that is, they all have the same

View File

@@ -241,6 +241,8 @@ 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)
// int32
TYPED_SFIELD(sfLoanScale, INT32, 1)

View File

@@ -402,6 +402,7 @@ class Loan_test : public beast::unit_test::suite
BrokerInfo const& broker,
LoanState const& state)
{
#if LOANCOMPLETE
if (auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
BEAST_EXPECT(brokerSle))
{
@@ -430,6 +431,7 @@ class Loan_test : public beast::unit_test::suite
}
}
}
#endif
return true;
}

View File

@@ -31,6 +31,16 @@ struct PreflightContext;
bool
checkLendingProtocolDependencies(PreflightContext const& ctx);
struct PaymentParts
{
Number rawInterest;
Number rawPrincipal;
Number roundedInterest;
Number roundedPrincipal;
Number roundedPayment;
bool final = false;
};
namespace detail {
// These functions should rarely be used directly. More often, the ultimate
// result needs to be roundToAsset'd.
@@ -74,11 +84,104 @@ minusManagementFee(Number const& value, TenthBips32 managementFeeRate)
return tenthBipsOfValue(value, tenthBipsPerUnity - managementFeeRate);
}
template <AssetType A>
PaymentParts
computePaymentParts(
A const& asset,
std::int32_t scale,
Number const& totalValueOutstanding,
Number const& principalOutstanding,
Number const& referencePrincipal,
Number const& periodicPayment,
Number const& periodicRate,
std::uint32_t paymentRemaining)
{
/*
* This function is derived from the XLS-66 spec, section 3.2.4.1.1 (Regular
* Payment)
*/
XRPL_ASSERT_PARTS(
isRounded(asset, totalValueOutstanding, scale) &&
isRounded(asset, principalOutstanding, scale),
"ripple::detail::computePaymentParts",
"Outstanding values are rounded");
auto const roundedPeriodicPayment =
roundToAsset(asset, periodicPayment, scale, Number::upward);
if (paymentRemaining == 1 || totalValueOutstanding <= periodicPayment)
{
// If there's only one payment left, we need to pay off the principal.
//
// The totalValueOutstanding should never be less than the
// periodicPayment until the last scheduled payment, but if it ever is,
// make it the last payment.
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::computePaymentParts",
"last payment is complete");
Number const interest = totalValueOutstanding - principalOutstanding;
return {
.rawInterest = rawInterest,
.rawPrincipal = referencePrincipal,
.roundedInterest = roundedInterest,
.roundedPrincipal = principalOutstanding,
.roundedPayment = roundedPeriodicPayment,
.final = true};
}
/*
* From the spec, once the periodicPayment is computed:
*
* The principal and interest portions can be derived as follows:
* interest = principalOutstanding * periodicRate
* principal = periodicPayment - interest
*
* Because those values deal with funds, they need to be rounded.
*/
Number const rawInterest = referencePrincipal * periodicRate;
Number const rawPrincipal = periodicPayment - rawInterest;
XRPL_ASSERT_PARTS(
rawInterest >= 0,
"ripple::detail::computePaymentParts",
"valid raw interest");
XRPL_ASSERT_PARTS(
rawPrincipal > 0 && rawPrincipal <= referencePrincipal,
"ripple::detail::computePaymentParts",
"valid raw principal");
Number const roundedInterest =
roundToAsset(asset, rawInterest, scale, Number::upward);
Number const roundedPrincipal = roundedPeriodicPayment - roundedInterest;
XRPL_ASSERT_PARTS(
roundedInterest >= 0,
"ripple::detail::computePaymentParts",
"valid rounded interest");
XRPL_ASSERT_PARTS(
roundedPrincipal >= 0 && roundedPrincipal <= principalOutstanding,
"ripple::detail::computePaymentParts",
"valid rounded principal");
XRPL_ASSERT_PARTS(
isRounded(asset, roundedPrincipal, scale),
"ripple::detail::computePaymentParts",
"principal is rounded");
return {
.rawInterest = rawInterest,
.rawPrincipal = rawPrincipal,
.roundedInterest = roundedInterest,
.roundedPrincipal = roundedPrincipal,
.roundedPayment = roundedPeriodicPayment};
}
} // namespace detail
template <AssetType A>
Number
valueMinusManagementFee(
valueMinusFee(
A const& asset,
Number const& value,
TenthBips32 managementFeeRate,
@@ -88,6 +191,90 @@ valueMinusManagementFee(
asset, detail::minusManagementFee(value, managementFeeRate), scale);
}
struct LoanProperties
{
Number periodicPayment;
Number totalValueOutstanding;
Number interestOwedToVault;
std::int32_t loanScale;
Number firstPaymentPrincipal;
};
template <AssetType A>
LoanProperties
computeLoanProperties(
A const& asset,
Number const& principalOutstanding,
Number const& referencePrincipal,
TenthBips32 interestRate,
std::uint32_t paymentInterval,
std::uint32_t paymentsRemaining,
TenthBips32 managementFeeRate)
{
auto const periodicRate =
detail::loanPeriodicRate(interestRate, paymentInterval);
auto const periodicPayment = detail::loanPeriodicPayment(
principalOutstanding, periodicRate, paymentsRemaining);
Number const totalValueOutstanding = [&]() {
NumberRoundModeGuard mg(Number::to_nearest);
// Use STAmount's internal rounding instead of roundToAsset, because
// we're going to use this result to determine the scale for all the
// other rounding.
return STAmount{
asset,
/*
* This formula is from the XLS-66 spec, section 3.2.4.2 (Total
* Loan Value Calculation), specifically "totalValueOutstanding
* = ..."
*/
periodicPayment * paymentsRemaining};
}();
// Base the loan scale on the total value, since that's going to be the
// biggest number involved
auto const loanScale = totalValueOutstanding.exponent();
auto const firstPaymentPrincipal = [&]() {
// Compute the unrounded parts for the first payment. Ensure that the
// principal payment will actually change the principal.
auto const paymentParts = detail::computePaymentParts(
asset,
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 - paymentParts.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 paymentParts.rawPrincipal;
}();
auto const interestOwedToVault = valueMinusFee(
asset,
/*
* This formula is from the XLS-66 spec, section 3.2.4.2 (Total Loan
* Value Calculation), specifically "totalInterestOutstanding = ..."
*/
totalValueOutstanding - principalOutstanding,
managementFeeRate,
loanScale);
return LoanProperties{
.periodicPayment = periodicPayment,
.totalValueOutstanding = totalValueOutstanding,
.interestOwedToVault = interestOwedToVault,
.loanScale = loanScale,
.firstPaymentPrincipal = firstPaymentPrincipal};
}
#if LOANCOMPLETE
inline Number
loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval)
{
@@ -211,6 +398,7 @@ loanTotalInterestOutstanding(
paymentInterval,
paymentsRemaining));
}
#endif
template <AssetType A>
Number
@@ -220,10 +408,11 @@ loanInterestOutstandingMinusFee(
TenthBips32 managementFeeRate,
std::int32_t scale)
{
return valueMinusManagementFee(
return valueMinusFee(
asset, totalInterestOutstanding, managementFeeRate, scale);
}
#if LOANCOMPLETE
template <AssetType A>
Number
loanInterestOutstandingMinusFee(
@@ -247,6 +436,7 @@ loanInterestOutstandingMinusFee(
managementFeeRate,
scale);
}
#endif
template <AssetType A>
Number
@@ -278,98 +468,6 @@ isRounded(A const& asset, Number const& value, std::int32_t scale)
roundToAsset(asset, value, scale, Number::upward) == value;
}
struct PaymentParts
{
Number interest;
Number principal;
Number fee;
};
template <AssetType A>
PaymentParts
computePaymentParts(
A const& asset,
std::int32_t scale,
Number const& totalValueOutstanding,
Number const& principalOutstanding,
Number const& periodicPaymentAmount,
Number const& serviceFee,
Number const& periodicRate,
std::uint32_t paymentRemaining)
{
/*
* This function is derived from the XLS-66 spec, section 3.2.4.1.1 (Regular
* Payment)
*/
XRPL_ASSERT_PARTS(
isRounded(asset, totalValueOutstanding, scale) &&
isRounded(asset, principalOutstanding, scale) &&
isRounded(asset, periodicPaymentAmount, scale),
"ripple::computePaymentParts",
"Asset values are rounded");
Number const roundedFee = roundToAsset(asset, serviceFee, scale);
if (paymentRemaining == 1 || periodicPaymentAmount > totalValueOutstanding)
{
// If there's only one payment left, we need to pay off the principal.
Number const interest = totalValueOutstanding - principalOutstanding;
return {
.interest = interest,
.principal = principalOutstanding,
.fee = roundedFee};
}
/*
* From the spec, once the periodicPayment is computed:
*
* The principal and interest portions can be derived as follows:
* interest = principalOutstanding * periodicRate
* principal = periodicPayment - interest
*
* Because those values deal with funds, they need to be rounded.
*/
Number const interest = roundToAsset(
asset, principalOutstanding * periodicRate, scale, Number::upward);
XRPL_ASSERT(
interest >= 0,
"ripple::detail::computePeriodicPayment : valid interest");
// To compute the principal using the above formulas, use the rounded
// payment amount, ensuring that some principal is paid regardless of any
// other results.
auto const roundedPayment = [&]() {
auto roundedPayment =
roundToAsset(asset, periodicPaymentAmount, scale, Number::upward);
if (roundedPayment > interest)
return roundedPayment;
auto newPayment = roundedPayment;
if (asset.native() || !asset.template holds<Issue>())
{
// integral types, just add one
++newPayment;
}
else
{
// Non-integral types: IOU. Add "dust" that will not be lost in
// rounding.
auto const epsilon = Number{1, scale - 14};
newPayment += epsilon;
}
roundedPayment = roundToAsset(asset, newPayment, scale);
XRPL_ASSERT_PARTS(
roundedPayment == newPayment,
"ripple::computePaymentParts",
"epsilon preserved in rounding");
return roundedPayment;
}();
Number const principal =
roundToAsset(asset, roundedPayment - interest, scale);
XRPL_ASSERT_PARTS(
principal > 0 && principal <= principalOutstanding,
"ripple::computePaymentParts",
"valid principal");
return {.interest = interest, .principal = principal, .fee = roundedFee};
}
// This structure is explained in the XLS-66 spec, section 3.2.4.4 (Failure
// Conditions)
struct LoanPaymentParts
@@ -420,6 +518,8 @@ handleLatePayment(
STAmount const& amount,
beast::Journal j)
{
return Unexpected(temDISABLED);
#if LOANCOMPLETE
if (!hasExpired(view, nextDueDateProxy))
return Unexpected(tesSUCCESS);
@@ -466,6 +566,7 @@ handleLatePayment(
.interestPaid = late.interest,
.valueChange = latePaymentInterest,
.feePaid = late.fee};
#endif
}
/* Handle possible full payments.

View File

@@ -361,6 +361,8 @@ LoanManage::unimpairLoan(
TER
LoanManage::doApply()
{
return temDISABLED;
#if LOANCOMPLETE
auto const& tx = ctx_.tx;
auto& view = ctx_.view();
@@ -435,6 +437,7 @@ LoanManage::doApply()
}
return tesSUCCESS;
#endif
}
//------------------------------------------------------------------------------

View File

@@ -285,10 +285,7 @@ LoanSet::doApply()
{
return tefBAD_LEDGER; // LCOV_EXCL_LINE
}
auto const principalRequested = [&](Number const& requested) {
return roundToAsset(vaultAsset, requested, requested.exponent());
}(tx[sfPrincipalRequested]);
auto const loanScale = principalRequested.exponent();
auto const principalRequested = tx[sfPrincipalRequested];
if (auto const assetsAvailable = vaultSle->at(sfAssetsAvailable);
assetsAvailable < principalRequested)
@@ -300,53 +297,68 @@ LoanSet::doApply()
TenthBips32 const interestRate{tx[~sfInterestRate].value_or(0)};
auto const originationFee = roundToAsset(
vaultAsset, tx[~sfLoanOriginationFee].value_or(Number{}), loanScale);
auto const loanAssetsToBorrower = principalRequested - originationFee;
auto const paymentInterval =
tx[~sfPaymentInterval].value_or(defaultPaymentInterval);
auto const paymentTotal = tx[~sfPaymentTotal].value_or(defaultPaymentTotal);
auto const periodicRate = loanPeriodicRate(interestRate, paymentInterval);
auto const periodicPayment = loanPeriodicPayment(
vaultAsset, principalRequested, periodicRate, paymentTotal, loanScale);
auto const totalValueOutstanding = loanTotalValueOutstanding(
vaultAsset, loanScale, periodicPayment, paymentTotal);
auto const properties = computeLoanProperties(
vaultAsset,
principalRequested,
principalRequested,
interestRate,
paymentInterval,
paymentTotal,
TenthBips32{brokerSle->at(sfManagementFeeRate)});
if (properties.firstPaymentPrincipal <= 0)
{
// Check that some principal is paid each period. Since the first
// payment pays the least principal, if it's good, they'll all be good.
auto const paymentParts = computePaymentParts(
vaultAsset,
loanScale,
totalValueOutstanding,
principalRequested,
periodicPayment,
tx[~sfLoanServiceFee].value_or(Number{}),
periodicRate,
paymentTotal);
if (paymentParts.principal <= 0)
{
JLOG(j_.warn()) << "Loan is unable to pay principal.";
return tecLIMIT_EXCEEDED;
}
// 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 tecLIMIT_EXCEEDED;
}
// Check that the other computed values are valid
if (properties.interestOwedToVault < 0 ||
properties.totalValueOutstanding <= 0 ||
properties.periodicPayment <= 0)
{
// LCOV_EXCL_START
JLOG(j_.warn())
<< "Computed loan properties are invalid. Does not compute.";
return tecINTERNAL;
// LCOV_EXCL_STOP
}
TenthBips32 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
// Check that relevant values won't lose precision
{
static std::map<std::string, OptionaledField<STNumber>> const
valueFields{
{"Principal Requested", ~sfPrincipalRequested},
{"Origination fee", ~sfLoanOriginationFee},
{"Service fee", ~sfLoanServiceFee},
{"Late Payment fee", ~sfLatePaymentFee},
{"Close Payment fee", ~sfClosePaymentFee}
// Overpayment fee is really a rate. Don't include it.
};
for (auto const& [name, field] : valueFields)
{
if (auto const value = tx[field];
value && !isRounded(vaultAsset, *value, properties.loanScale))
{
JLOG(j_.warn()) << name << " has too much precision.";
return tecPRECISION_LOSS;
}
}
}
auto const originationFee = tx[~sfLoanOriginationFee].value_or(Number{});
auto const totalInterestOwedToVault = [&]() {
auto const totalInterestOutstanding = loanTotalInterestOutstanding(
principalRequested, totalValueOutstanding);
auto const loanAssetsToBorrower = principalRequested - originationFee;
return loanInterestOutstandingMinusFee(
vaultAsset, totalInterestOutstanding, managementFeeRate, loanScale);
}();
auto const newDebtTotal = brokerSle->at(sfDebtTotal) + principalRequested +
totalInterestOwedToVault;
auto const newDebtDelta =
principalRequested + properties.interestOwedToVault;
auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta;
if (auto const debtMaximum = brokerSle->at(sfDebtMaximum);
debtMaximum != 0 && debtMaximum < newDebtTotal)
{
@@ -458,7 +470,10 @@ LoanSet::doApply()
setLoanField(~sfGracePeriod, defaultGracePeriod);
// Set dynamic / computed fields to their initial values
loan->at(sfPrincipalOutstanding) = principalRequested;
loan->at(sfTotalValueOutstanding) = totalValueOutstanding;
loan->at(sfReferencePrincipal) = principalRequested;
loan->at(sfPeriodicPayment) = properties.periodicPayment;
loan->at(sfTotalValueOutstanding) = properties.totalValueOutstanding;
loan->at(sfInterestOwed) = properties.interestOwedToVault;
loan->at(sfPreviousPaymentDate) = 0;
loan->at(sfNextPaymentDueDate) = startDate + paymentInterval;
loan->at(sfPaymentRemaining) = paymentTotal;
@@ -466,7 +481,7 @@ LoanSet::doApply()
// Update the balances in the vault
vaultSle->at(sfAssetsAvailable) -= principalRequested;
vaultSle->at(sfAssetsTotal) += totalInterestOwedToVault;
vaultSle->at(sfAssetsTotal) += properties.interestOwedToVault;
XRPL_ASSERT_PARTS(
*vaultSle->at(sfAssetsAvailable) <= *vaultSle->at(sfAssetsTotal),
"ripple::LoanSet::doApply",
@@ -474,7 +489,7 @@ LoanSet::doApply()
view.update(vaultSle);
// Update the balances in the loan broker
brokerSle->at(sfDebtTotal) += principalRequested + totalInterestOwedToVault;
brokerSle->at(sfDebtTotal) += newDebtDelta;
// The broker's owner count is solely for the number of outstanding loans,
// and is distinct from the broker's pseudo-account's owner count
adjustOwnerCount(view, brokerSle, 1, j_);