mirror of
https://github.com/XRPLF/rippled.git
synced 2026-07-17 04:00:21 +00:00
Compare commits
1 Commits
develop
...
tapanito/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1a0f94c2e |
@@ -285,6 +285,61 @@ computeFullPaymentInterest(
|
||||
std::uint32_t startDate,
|
||||
TenthBips32 closeInterestRate);
|
||||
|
||||
// Deltas applied to Vault.AssetsTotal and LoanBroker.DebtTotal at a single
|
||||
// accounting touch point (origination, payment, impair/unimpair/default).
|
||||
struct AccountingDeltas
|
||||
{
|
||||
Number assetsTotalDelta;
|
||||
Number debtTotalDelta;
|
||||
};
|
||||
|
||||
// Whole-life (pre-LendingProtocolV1_1) recognition model: interest is
|
||||
// recognized into AssetsTotal/DebtTotal up front, at origination.
|
||||
namespace Accrual {
|
||||
|
||||
// LoanSet origination: what's added to Vault.AssetsTotal and LoanBroker.DebtTotal
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(Number const& principalRequested, Number const& interestDue);
|
||||
|
||||
// LoanManage impair/unimpair/default: the vault's exposure to this loan
|
||||
Number
|
||||
loanVaultExposure(SLE::ref loanSle);
|
||||
|
||||
// LoanPay: what's added to Vault.AssetsTotal and subtracted from LoanBroker.DebtTotal for a payment
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(LoanPaymentParts const& parts);
|
||||
|
||||
} // namespace Accrual
|
||||
|
||||
// Cash-basis (LendingProtocolV1_1) recognition model: AssetsTotal/DebtTotal
|
||||
// are principal-only, interest is recognized only as it's actually paid.
|
||||
namespace CashBasis {
|
||||
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(Number const& principalRequested, Number const& interestDue);
|
||||
|
||||
Number
|
||||
loanVaultExposure(SLE::ref loanSle);
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(LoanPaymentParts const& parts);
|
||||
|
||||
} // namespace CashBasis
|
||||
|
||||
// Public dispatchers: pick CashBasis:: if featureLendingProtocolV1_1 is
|
||||
// enabled, else Accrual::. These are the only entry points transactors call.
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(
|
||||
Rules const& rules,
|
||||
Number const& principalRequested,
|
||||
Number const& interestDue);
|
||||
|
||||
Number
|
||||
loanVaultExposure(Rules const& rules, SLE::ref loanSle);
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(Rules const& rules, LoanPaymentParts const& parts);
|
||||
|
||||
namespace detail {
|
||||
// These classes and functions should only be accessed by LendingHelper
|
||||
// functions and unit tests
|
||||
|
||||
@@ -130,6 +130,77 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
|
||||
roundToAsset(asset, value, scale, Number::RoundingMode::Upward);
|
||||
}
|
||||
|
||||
namespace Accrual {
|
||||
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(Number const& principalRequested, Number const& interestDue)
|
||||
{
|
||||
return {.assetsTotalDelta = interestDue, .debtTotalDelta = principalRequested + interestDue};
|
||||
}
|
||||
|
||||
Number
|
||||
loanVaultExposure(SLE::ref loanSle)
|
||||
{
|
||||
return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding);
|
||||
}
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(LoanPaymentParts const& parts)
|
||||
{
|
||||
return {
|
||||
.assetsTotalDelta = parts.valueChange,
|
||||
.debtTotalDelta = (parts.principalPaid + parts.interestPaid) - parts.valueChange};
|
||||
}
|
||||
|
||||
} // namespace Accrual
|
||||
|
||||
namespace CashBasis {
|
||||
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(Number const& principalRequested, Number const&)
|
||||
{
|
||||
return {.assetsTotalDelta = kNumZero, .debtTotalDelta = principalRequested};
|
||||
}
|
||||
|
||||
Number
|
||||
loanVaultExposure(SLE::ref loanSle)
|
||||
{
|
||||
return loanSle->at(sfPrincipalOutstanding);
|
||||
}
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(LoanPaymentParts const& parts)
|
||||
{
|
||||
return {.assetsTotalDelta = parts.interestPaid, .debtTotalDelta = parts.principalPaid};
|
||||
}
|
||||
|
||||
} // namespace CashBasis
|
||||
|
||||
AccountingDeltas
|
||||
loanOriginationDeltas(
|
||||
Rules const& rules,
|
||||
Number const& principalRequested,
|
||||
Number const& interestDue)
|
||||
{
|
||||
return rules.enabled(featureLendingProtocolV1_1)
|
||||
? CashBasis::loanOriginationDeltas(principalRequested, interestDue)
|
||||
: Accrual::loanOriginationDeltas(principalRequested, interestDue);
|
||||
}
|
||||
|
||||
Number
|
||||
loanVaultExposure(Rules const& rules, SLE::ref loanSle)
|
||||
{
|
||||
return rules.enabled(featureLendingProtocolV1_1) ? CashBasis::loanVaultExposure(loanSle)
|
||||
: Accrual::loanVaultExposure(loanSle);
|
||||
}
|
||||
|
||||
AccountingDeltas
|
||||
loanPaymentDeltas(Rules const& rules, LoanPaymentParts const& parts)
|
||||
{
|
||||
return rules.enabled(featureLendingProtocolV1_1) ? CashBasis::loanPaymentDeltas(parts)
|
||||
: Accrual::loanPaymentDeltas(parts);
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
|
||||
void
|
||||
|
||||
@@ -127,23 +127,6 @@ LoanManage::preclaim(PreclaimContext const& ctx)
|
||||
return tesSUCCESS;
|
||||
}
|
||||
|
||||
static Number
|
||||
owedToVault(SLE::ref loanSle)
|
||||
{
|
||||
// Spec section 3.2.3.2, defines the default amount as
|
||||
//
|
||||
// DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding)
|
||||
//
|
||||
// Loan.InterestOutstanding is not stored directly on ledger.
|
||||
// It is computed as
|
||||
//
|
||||
// Loan.TotalValueOutstanding - Loan.PrincipalOutstanding -
|
||||
// Loan.ManagementFeeOutstanding
|
||||
//
|
||||
// Add that to the original formula, and you get this:
|
||||
return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding);
|
||||
}
|
||||
|
||||
TER
|
||||
LoanManage::defaultLoan(
|
||||
ApplyView& view,
|
||||
@@ -158,7 +141,7 @@ LoanManage::defaultLoan(
|
||||
std::int32_t const loanScale = loanSle->at(sfLoanScale);
|
||||
auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal);
|
||||
|
||||
Number const totalDefaultAmount = owedToVault(loanSle);
|
||||
Number const totalDefaultAmount = loanVaultExposure(view.rules(), loanSle);
|
||||
|
||||
// Apply the First-Loss Capital to the Default Amount
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
@@ -304,7 +287,7 @@ LoanManage::impairLoan(
|
||||
Asset const& vaultAsset,
|
||||
beast::Journal j)
|
||||
{
|
||||
Number const lossUnrealized = owedToVault(loanSle);
|
||||
Number const lossUnrealized = loanVaultExposure(view.rules(), loanSle);
|
||||
|
||||
// The vault may be at a different scale than the loan. Reduce rounding
|
||||
// errors during the accounting by rounding some of the values to that
|
||||
@@ -353,7 +336,7 @@ LoanManage::unimpairLoan(
|
||||
|
||||
// Update the Vault object(clear "paper loss")
|
||||
auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized);
|
||||
Number const lossReversed = owedToVault(loanSle);
|
||||
Number const lossReversed = loanVaultExposure(view.rules(), loanSle);
|
||||
if (vaultLossUnrealizedProxy < lossReversed)
|
||||
{
|
||||
// LCOV_EXCL_START
|
||||
|
||||
@@ -420,10 +420,13 @@ LoanPay::doApply()
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(view.rules(), *paymentParts);
|
||||
|
||||
JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid
|
||||
<< ", interest paid: " << paymentParts->interestPaid
|
||||
<< ", fee paid: " << paymentParts->feePaid
|
||||
<< ", value change: " << paymentParts->valueChange;
|
||||
<< ", assets total delta: " << assetsTotalDelta
|
||||
<< ", debt total delta: " << debtTotalDelta;
|
||||
|
||||
//------------------------------------------------------
|
||||
// LoanBroker object state changes
|
||||
@@ -439,13 +442,6 @@ LoanPay::doApply()
|
||||
!asset.integral() || totalPaidToVaultRaw == totalPaidToVaultRounded,
|
||||
"xrpl::LoanPay::doApply",
|
||||
"rounding does nothing for integral asset");
|
||||
// Account for value changes when reducing the broker's debt:
|
||||
// - Positive value change (from full/late/overpayments): Subtract from the
|
||||
// amount credited toward debt to avoid over-reducing the debt.
|
||||
// - Negative value change (from full/overpayments): Add to the amount
|
||||
// credited toward debt,effectively increasing the debt reduction.
|
||||
auto const totalPaidToVaultForDebt = totalPaidToVaultRaw - paymentParts->valueChange;
|
||||
|
||||
auto const totalPaidToBroker = paymentParts->feePaid;
|
||||
|
||||
XRPL_ASSERT_PARTS(
|
||||
@@ -455,16 +451,16 @@ LoanPay::doApply()
|
||||
"payments add up");
|
||||
|
||||
// Decrease LoanBroker Debt by the amount paid, add the Loan value change
|
||||
// (which might be negative). totalPaidToVaultForDebt may be negative,
|
||||
// increasing the debt
|
||||
// (which might be negative). debtTotalDelta may be negative, increasing the
|
||||
// debt
|
||||
XRPL_ASSERT_PARTS(
|
||||
isRounded(asset, totalPaidToVaultForDebt, loanScale),
|
||||
isRounded(asset, debtTotalDelta, loanScale),
|
||||
"xrpl::LoanPay::doApply",
|
||||
"totalPaidToVaultForDebt rounding good");
|
||||
"debtTotalDelta 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
|
||||
// than one loan with significantly different scales.
|
||||
adjustImpreciseNumber(debtTotalProxy, -totalPaidToVaultForDebt, asset, vaultScale);
|
||||
adjustImpreciseNumber(debtTotalProxy, -debtTotalDelta, asset, vaultScale);
|
||||
|
||||
//------------------------------------------------------
|
||||
// Vault object state changes
|
||||
@@ -490,7 +486,7 @@ LoanPay::doApply()
|
||||
#endif
|
||||
|
||||
assetsAvailableProxy += totalPaidToVaultRounded;
|
||||
assetsTotalProxy += paymentParts->valueChange;
|
||||
assetsTotalProxy += assetsTotalDelta;
|
||||
|
||||
XRPL_ASSERT_PARTS(
|
||||
*assetsAvailableProxy <= *assetsTotalProxy,
|
||||
@@ -543,11 +539,11 @@ LoanPay::doApply()
|
||||
return tecPRECISION_LOSS;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
if (paymentParts->valueChange != beast::kZero && assetsTotalAfter == assetsTotalBefore)
|
||||
if (assetsTotalDelta != beast::kZero && assetsTotalAfter == assetsTotalBefore)
|
||||
{
|
||||
// Non-zero valueChange with an unchanged assetsTotal indicates that the
|
||||
// actual value change rounded to zero. That should be impossible, but I
|
||||
// can't rule it out for extreme edge cases, so fail gracefully if it
|
||||
// Non-zero assetsTotalDelta with an unchanged assetsTotal indicates that
|
||||
// the actual value change rounded to zero. That should be impossible, but
|
||||
// I can't rule it out for extreme edge cases, so fail gracefully if it
|
||||
// happens.
|
||||
//
|
||||
// LCOV_EXCL_START
|
||||
@@ -555,20 +551,21 @@ LoanPay::doApply()
|
||||
<< "LoanPay: Vault assets expected change, but unchanged after rounding: " //
|
||||
<< "Before: " << assetsTotalBefore //
|
||||
<< ", After: " << assetsTotalAfter //
|
||||
<< ", ValueChange: " << paymentParts->valueChange;
|
||||
<< ", ValueChange: " << assetsTotalDelta;
|
||||
return tecPRECISION_LOSS;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
if (paymentParts->valueChange == beast::kZero && assetsTotalAfter != assetsTotalBefore)
|
||||
if (assetsTotalDelta == beast::kZero && assetsTotalAfter != assetsTotalBefore)
|
||||
{
|
||||
// A change in assetsTotal when there was no valueChange indicates that
|
||||
// something really weird happened. That should be flat out impossible.
|
||||
// A change in assetsTotal when there was no assetsTotalDelta indicates
|
||||
// that something really weird happened. That should be flat out
|
||||
// impossible.
|
||||
//
|
||||
// LCOV_EXCL_START
|
||||
JLOG(j_.fatal()) << "LoanPay: Vault assets changed unexpectedly after rounding: " //
|
||||
<< "Before: " << assetsTotalBefore //
|
||||
<< ", After: " << assetsTotalAfter //
|
||||
<< ", ValueChange: " << paymentParts->valueChange;
|
||||
<< ", ValueChange: " << assetsTotalDelta;
|
||||
return tecINTERNAL;
|
||||
// LCOV_EXCL_STOP
|
||||
}
|
||||
|
||||
@@ -633,8 +633,10 @@ LoanSet::doApply()
|
||||
view.insert(loan);
|
||||
|
||||
// Update the balances in the vault
|
||||
auto const [assetsTotalDelta, debtTotalDelta] =
|
||||
loanOriginationDeltas(ctx_.view().rules(), principalRequested, state.interestDue);
|
||||
vaultAvailableProxy -= principalRequested;
|
||||
vaultTotalProxy += state.interestDue;
|
||||
vaultTotalProxy += assetsTotalDelta;
|
||||
XRPL_ASSERT_PARTS(
|
||||
*vaultAvailableProxy <= *vaultTotalProxy,
|
||||
"xrpl::LoanSet::doApply",
|
||||
@@ -642,7 +644,7 @@ LoanSet::doApply()
|
||||
view.update(vaultSle);
|
||||
|
||||
// Update the balances in the loan broker
|
||||
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), newDebtDelta, vaultAsset, vaultScale);
|
||||
adjustImpreciseNumber(brokerSle->at(sfDebtTotal), debtTotalDelta, vaultAsset, vaultScale);
|
||||
adjustLoanBrokerOwnerCount(view, brokerSle, 1, j_);
|
||||
loanSequenceProxy += 1;
|
||||
// The sequence should be extremely unlikely to roll over, but fail if it
|
||||
|
||||
@@ -1470,6 +1470,215 @@ class LendingHelpers_test : public beast::unit_test::Suite
|
||||
Number{-18304, -5}));
|
||||
}
|
||||
|
||||
void
|
||||
testAccrualLoanOriginationDeltas()
|
||||
{
|
||||
using namespace xrpl::Accrual;
|
||||
|
||||
struct TestCase
|
||||
{
|
||||
std::string name;
|
||||
Number principalRequested;
|
||||
Number interestDue;
|
||||
};
|
||||
|
||||
auto const testCases = std::vector<TestCase>{
|
||||
{.name = "Zero interest",
|
||||
.principalRequested = Number{1'000},
|
||||
.interestDue = Number{0}},
|
||||
{.name = "Nonzero interest",
|
||||
.principalRequested = Number{1'000},
|
||||
.interestDue = Number{75}},
|
||||
};
|
||||
|
||||
for (auto const& tc : testCases)
|
||||
{
|
||||
testcase("Accrual::loanOriginationDeltas: " + tc.name);
|
||||
|
||||
auto const deltas = loanOriginationDeltas(tc.principalRequested, tc.interestDue);
|
||||
BEAST_EXPECTS(
|
||||
deltas.assetsTotalDelta == tc.interestDue,
|
||||
"assetsTotalDelta mismatch: expected " + to_string(tc.interestDue) + ", got " +
|
||||
to_string(deltas.assetsTotalDelta));
|
||||
BEAST_EXPECTS(
|
||||
deltas.debtTotalDelta == tc.principalRequested + tc.interestDue,
|
||||
"debtTotalDelta mismatch: expected " +
|
||||
to_string(tc.principalRequested + tc.interestDue) + ", got " +
|
||||
to_string(deltas.debtTotalDelta));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testCashBasisLoanOriginationDeltas()
|
||||
{
|
||||
using namespace xrpl::CashBasis;
|
||||
|
||||
testcase("CashBasis::loanOriginationDeltas: interestDue is ignored");
|
||||
|
||||
Number const principalRequested{1'000};
|
||||
Number const interestDue{75};
|
||||
|
||||
auto const deltas = loanOriginationDeltas(principalRequested, interestDue);
|
||||
BEAST_EXPECTS(
|
||||
deltas.assetsTotalDelta == 0,
|
||||
"assetsTotalDelta mismatch: expected 0, got " + to_string(deltas.assetsTotalDelta));
|
||||
BEAST_EXPECTS(
|
||||
deltas.debtTotalDelta == principalRequested,
|
||||
"debtTotalDelta mismatch: expected " + to_string(principalRequested) + ", got " +
|
||||
to_string(deltas.debtTotalDelta));
|
||||
}
|
||||
|
||||
// Constructs a minimal ltLOAN SLE with just the fields needed by
|
||||
// loanVaultExposure. Mirrors the bare-SLE pattern used by
|
||||
// testCanApplyToBrokerCover for ltLOAN_BROKER.
|
||||
static std::shared_ptr<SLE>
|
||||
makeLoanSle(
|
||||
Number const& totalValueOutstanding,
|
||||
Number const& principalOutstanding,
|
||||
Number const& managementFeeOutstanding)
|
||||
{
|
||||
auto sle = std::make_shared<SLE>(ltLOAN, uint256{1u});
|
||||
sle->at(sfTotalValueOutstanding) = totalValueOutstanding;
|
||||
sle->at(sfPrincipalOutstanding) = principalOutstanding;
|
||||
sle->at(sfManagementFeeOutstanding) = managementFeeOutstanding;
|
||||
return sle;
|
||||
}
|
||||
|
||||
void
|
||||
testAccrualLoanVaultExposure()
|
||||
{
|
||||
testcase("Accrual::loanVaultExposure");
|
||||
|
||||
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
|
||||
BEAST_EXPECT(xrpl::Accrual::loanVaultExposure(sle) == Number{950});
|
||||
}
|
||||
|
||||
void
|
||||
testCashBasisLoanVaultExposure()
|
||||
{
|
||||
testcase("CashBasis::loanVaultExposure");
|
||||
|
||||
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
|
||||
BEAST_EXPECT(xrpl::CashBasis::loanVaultExposure(sle) == Number{800});
|
||||
}
|
||||
|
||||
void
|
||||
testLoanPaymentDeltas()
|
||||
{
|
||||
// principalPaid, interestPaid, feePaid, valueChange are all distinct
|
||||
// and nonzero, with a nonzero valueChange simulating a late-payment
|
||||
// penalty, so Accrual's formula is meaningfully exercised.
|
||||
LoanPaymentParts const parts{
|
||||
.principalPaid = Number{100},
|
||||
.interestPaid = Number{20},
|
||||
.valueChange = Number{5},
|
||||
.feePaid = Number{3}};
|
||||
|
||||
{
|
||||
testcase("Accrual::loanPaymentDeltas: nonzero valueChange");
|
||||
auto const deltas = xrpl::Accrual::loanPaymentDeltas(parts);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == parts.valueChange);
|
||||
BEAST_EXPECT(
|
||||
deltas.debtTotalDelta ==
|
||||
(parts.principalPaid + parts.interestPaid) - parts.valueChange);
|
||||
}
|
||||
|
||||
{
|
||||
testcase("CashBasis::loanPaymentDeltas: nonzero valueChange ignored");
|
||||
auto const deltas = xrpl::CashBasis::loanPaymentDeltas(parts);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == parts.interestPaid);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == parts.principalPaid);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testLoanOriginationDeltasDispatcher()
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
Number const principalRequested{1'000};
|
||||
Number const interestDue{75};
|
||||
|
||||
{
|
||||
testcase("loanOriginationDeltas dispatcher: amendment disabled picks Accrual");
|
||||
Env env{*this};
|
||||
env.disableFeature(featureLendingProtocolV1_1);
|
||||
auto const deltas =
|
||||
loanOriginationDeltas(env.current()->rules(), principalRequested, interestDue);
|
||||
auto const expected =
|
||||
xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
|
||||
}
|
||||
|
||||
{
|
||||
testcase("loanOriginationDeltas dispatcher: amendment enabled picks CashBasis");
|
||||
Env const env{*this};
|
||||
auto const deltas =
|
||||
loanOriginationDeltas(env.current()->rules(), principalRequested, interestDue);
|
||||
auto const expected =
|
||||
xrpl::CashBasis::loanOriginationDeltas(principalRequested, interestDue);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testLoanVaultExposureDispatcher()
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
{
|
||||
testcase("loanVaultExposure dispatcher: amendment disabled picks Accrual");
|
||||
Env env{*this};
|
||||
env.disableFeature(featureLendingProtocolV1_1);
|
||||
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
|
||||
BEAST_EXPECT(
|
||||
loanVaultExposure(env.current()->rules(), sle) ==
|
||||
xrpl::Accrual::loanVaultExposure(sle));
|
||||
}
|
||||
|
||||
{
|
||||
testcase("loanVaultExposure dispatcher: amendment enabled picks CashBasis");
|
||||
Env const env{*this};
|
||||
auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50});
|
||||
BEAST_EXPECT(
|
||||
loanVaultExposure(env.current()->rules(), sle) ==
|
||||
xrpl::CashBasis::loanVaultExposure(sle));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testLoanPaymentDeltasDispatcher()
|
||||
{
|
||||
using namespace jtx;
|
||||
|
||||
LoanPaymentParts const parts{
|
||||
.principalPaid = Number{100},
|
||||
.interestPaid = Number{20},
|
||||
.valueChange = Number{5},
|
||||
.feePaid = Number{3}};
|
||||
|
||||
{
|
||||
testcase("loanPaymentDeltas dispatcher: amendment disabled picks Accrual");
|
||||
Env env{*this};
|
||||
env.disableFeature(featureLendingProtocolV1_1);
|
||||
auto const deltas = loanPaymentDeltas(env.current()->rules(), parts);
|
||||
auto const expected = xrpl::Accrual::loanPaymentDeltas(parts);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
|
||||
}
|
||||
|
||||
{
|
||||
testcase("loanPaymentDeltas dispatcher: amendment enabled picks CashBasis");
|
||||
Env const env{*this};
|
||||
auto const deltas = loanPaymentDeltas(env.current()->rules(), parts);
|
||||
auto const expected = xrpl::CashBasis::loanPaymentDeltas(parts);
|
||||
BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta);
|
||||
BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
testCanApplyToBrokerCover()
|
||||
@@ -1573,6 +1782,15 @@ public:
|
||||
testComputeOverpaymentComponents();
|
||||
testComputeInterestAndFeeParts();
|
||||
testCanApplyToBrokerCover();
|
||||
|
||||
testAccrualLoanOriginationDeltas();
|
||||
testCashBasisLoanOriginationDeltas();
|
||||
testAccrualLoanVaultExposure();
|
||||
testCashBasisLoanVaultExposure();
|
||||
testLoanPaymentDeltas();
|
||||
testLoanOriginationDeltasDispatcher();
|
||||
testLoanVaultExposureDispatcher();
|
||||
testLoanPaymentDeltasDispatcher();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -92,8 +92,13 @@ class Loan_test : public beast::unit_test::Suite
|
||||
protected:
|
||||
// Ensure that all the features needed for Lending Protocol are included,
|
||||
// even if they are set to unsupported.
|
||||
|
||||
FeatureBitset const all_{jtx::testableAmendments()};
|
||||
//
|
||||
// featureLendingProtocolV1_1 is excluded from the default set: it changes
|
||||
// Vault/LoanBroker accounting (AssetsTotal/DebtTotal/LossUnrealized), and
|
||||
// most of this file's tests assert whole-life-specific expected values
|
||||
// for those fields. Tests that specifically exercise the amendment opt
|
||||
// it back in explicitly (e.g. `all_ | featureLendingProtocolV1_1`).
|
||||
FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
|
||||
std::string const iouCurrency_{"IOU"};
|
||||
|
||||
void
|
||||
@@ -363,7 +368,9 @@ protected:
|
||||
{
|
||||
TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)};
|
||||
auto const brokerDebt = brokerSle->at(sfDebtTotal);
|
||||
auto const expectedDebt = principalOutstanding + interestOwed;
|
||||
auto const expectedDebt = env.current()->rules().enabled(featureLendingProtocolV1_1)
|
||||
? principalOutstanding
|
||||
: principalOutstanding + interestOwed;
|
||||
env.test.BEAST_EXPECT(brokerDebt == expectedDebt);
|
||||
env.test.BEAST_EXPECT(
|
||||
env.balance(pseudoAccount, broker.asset).number() ==
|
||||
@@ -468,7 +475,9 @@ protected:
|
||||
{
|
||||
env.test.BEAST_EXPECT(
|
||||
vaultSle->at(sfLossUnrealized) ==
|
||||
totalValue - managementFeeOutstanding);
|
||||
(env.current()->rules().enabled(featureLendingProtocolV1_1)
|
||||
? principalOutstanding
|
||||
: totalValue - managementFeeOutstanding));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -635,8 +644,10 @@ protected:
|
||||
// log << vaultSle->getJson() << std::endl;
|
||||
auto const assetsUnavailable =
|
||||
vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable);
|
||||
auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + state.totalValue -
|
||||
state.managementFeeOutstanding;
|
||||
auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) +
|
||||
(env.current()->rules().enabled(featureLendingProtocolV1_1)
|
||||
? state.principalOutstanding
|
||||
: state.totalValue - state.managementFeeOutstanding);
|
||||
|
||||
if (!BEAST_EXPECT(unrealizedLoss <= assetsUnavailable))
|
||||
{
|
||||
@@ -8547,6 +8558,772 @@ protected:
|
||||
});
|
||||
}
|
||||
|
||||
// LendingProtocolV1_1 ("cash-basis" accounting) dedicated coverage.
|
||||
//
|
||||
// Existing tests never enable featureLendingProtocolV1_1 (see `all_`
|
||||
// above), so these are the only tests in this file that exercise the
|
||||
// amendment. They are called once, directly, from
|
||||
// runAmendmentIndependent() -- not looped through
|
||||
// runAmendmentSensitive()/amendmentCombinations(), since doing so would
|
||||
// require re-deriving whole-life-specific expected values for ~15
|
||||
// unrelated regression tests.
|
||||
|
||||
// 1. LoanSet origination: Vault.AssetsTotal/LoanBroker.DebtTotal deltas,
|
||||
// and the AssetsMaximum/DebtMaximum guards (which always check against
|
||||
// principal + interestDue, regardless of the amendment).
|
||||
void
|
||||
testCashBasisLoanSetOrigination()
|
||||
{
|
||||
testcase("cash-basis: LoanSet origination");
|
||||
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 100'000,
|
||||
.debtMax = 0,
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0}};
|
||||
|
||||
Number const principalRequest{10'000};
|
||||
TenthBips32 const interestRate{percentageToTenthBips(10)};
|
||||
std::uint32_t const paymentTotal = 2;
|
||||
std::uint32_t const paymentInterval = 86400;
|
||||
|
||||
// Creates a broker/vault, submits a single LoanSet with a nonzero
|
||||
// interest rate, and returns the observed Vault.AssetsTotal /
|
||||
// LoanBroker.DebtTotal deltas plus the loan's own computed
|
||||
// interestDue and principalOutstanding.
|
||||
auto runOrigination = [&](FeatureBitset features) {
|
||||
Env env(*this, features);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
auto const vaultBefore = env.le(broker.vaultKeylet());
|
||||
auto const brokerBefore = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(vaultBefore && brokerBefore);
|
||||
Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
|
||||
Number const debtTotalBefore = brokerBefore->at(sfDebtTotal);
|
||||
|
||||
auto const loanSequence = brokerBefore->at(sfLoanSequence);
|
||||
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
|
||||
|
||||
env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()),
|
||||
kCounterparty(lender),
|
||||
kInterestRate(interestRate),
|
||||
kPaymentTotal(paymentTotal),
|
||||
kPaymentInterval(paymentInterval),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const loanSle = env.le(loanKeylet);
|
||||
BEAST_EXPECT(loanSle);
|
||||
Number const principalOutstanding = loanSle->at(sfPrincipalOutstanding);
|
||||
Number const totalValueOutstanding = loanSle->at(sfTotalValueOutstanding);
|
||||
Number const interestDue = totalValueOutstanding - principalOutstanding;
|
||||
BEAST_EXPECT(interestDue > beast::kZero);
|
||||
BEAST_EXPECT(principalOutstanding == xrpAsset(principalRequest).value());
|
||||
|
||||
auto const vaultAfter = env.le(broker.vaultKeylet());
|
||||
auto const brokerAfter = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(vaultAfter && brokerAfter);
|
||||
Number const assetsTotalDelta =
|
||||
Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore;
|
||||
Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore;
|
||||
|
||||
return std::make_tuple(
|
||||
assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding);
|
||||
};
|
||||
|
||||
Number interestDueCash{};
|
||||
Number principalOutstandingCash{};
|
||||
{
|
||||
auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] =
|
||||
runOrigination(all_ | featureLendingProtocolV1_1);
|
||||
interestDueCash = interestDue;
|
||||
principalOutstandingCash = principalOutstanding;
|
||||
|
||||
BEAST_EXPECTS(
|
||||
assetsTotalDelta == beast::kZero,
|
||||
"cash-basis origination must not change AssetsTotal; delta=" +
|
||||
to_string(assetsTotalDelta));
|
||||
BEAST_EXPECTS(
|
||||
debtTotalDelta == principalOutstanding,
|
||||
"cash-basis origination must add principal-only to DebtTotal; delta=" +
|
||||
to_string(debtTotalDelta) + " principal=" + to_string(principalOutstanding));
|
||||
}
|
||||
|
||||
{
|
||||
auto const [assetsTotalDelta, debtTotalDelta, interestDue, principalOutstanding] =
|
||||
runOrigination(all_);
|
||||
|
||||
BEAST_EXPECTS(
|
||||
assetsTotalDelta == interestDue,
|
||||
"whole-life origination must add interestDue to AssetsTotal; delta=" +
|
||||
to_string(assetsTotalDelta) + " interestDue=" + to_string(interestDue));
|
||||
BEAST_EXPECTS(
|
||||
debtTotalDelta == principalOutstanding + interestDue,
|
||||
"whole-life origination must add principal+interest to DebtTotal; delta=" +
|
||||
to_string(debtTotalDelta));
|
||||
}
|
||||
|
||||
// AssetsMaximum / DebtMaximum guards: unconditionally checked against
|
||||
// principal + interestDue, regardless of the amendment. Verify that
|
||||
// the accept/reject boundary is bit-for-bit identical in both cases.
|
||||
auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) {
|
||||
Env env(*this, features);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
auto const vaultSle = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultSle);
|
||||
Number const assetsTotalBefore = vaultSle->at(sfAssetsTotal);
|
||||
|
||||
Vault const vault{env};
|
||||
auto tx = vault.set({.owner = lender, .id = broker.vaultID});
|
||||
tx[sfAssetsMaximum] = assetsTotalBefore + slack;
|
||||
env(tx);
|
||||
env.close();
|
||||
|
||||
env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()),
|
||||
kCounterparty(lender),
|
||||
kInterestRate(interestRate),
|
||||
kPaymentTotal(paymentTotal),
|
||||
kPaymentInterval(paymentInterval),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(expected));
|
||||
env.close();
|
||||
};
|
||||
|
||||
auto runBrokerGuard = [&](FeatureBitset features, Number const& debtMaximum, TER expected) {
|
||||
Env env(*this, features);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(1'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
env(loanBroker::set(lender, broker.vaultID),
|
||||
loanBroker::kLoanBrokerId(broker.brokerID),
|
||||
loanBroker::kDebtMaximum(debtMaximum),
|
||||
Fee(env.current()->fees().base * 2));
|
||||
env.close();
|
||||
|
||||
env(set(borrower, broker.brokerID, xrpAsset(principalRequest).value()),
|
||||
kCounterparty(lender),
|
||||
kInterestRate(interestRate),
|
||||
kPaymentTotal(paymentTotal),
|
||||
kPaymentInterval(paymentInterval),
|
||||
Sig(sfCounterpartySignature, lender),
|
||||
Fee(env.current()->fees().base * 2),
|
||||
Ter(expected));
|
||||
env.close();
|
||||
};
|
||||
|
||||
Number const oneDrop = xrpAsset(1).value();
|
||||
for (auto const features : {all_ | featureLendingProtocolV1_1, all_})
|
||||
{
|
||||
testcase(
|
||||
std::string("cash-basis: LoanSet AssetsMaximum guard unaffected by amendment (") +
|
||||
(features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)"));
|
||||
// Guard rejects when there's not quite enough headroom for the
|
||||
// interest, even though cash-basis never touches AssetsTotal for
|
||||
// interest.
|
||||
runVaultGuard(features, interestDueCash - oneDrop, tecLIMIT_EXCEEDED);
|
||||
// Guard accepts at the exact boundary.
|
||||
runVaultGuard(features, interestDueCash, tesSUCCESS);
|
||||
|
||||
testcase(
|
||||
std::string("cash-basis: LoanSet DebtMaximum guard unaffected by amendment (") +
|
||||
(features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)"));
|
||||
Number const newDebtTotal = principalOutstandingCash + interestDueCash;
|
||||
runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED);
|
||||
runBrokerGuard(features, newDebtTotal, tesSUCCESS);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. LoanPay: regular, late, overpayment, and full-payment types.
|
||||
// Assert Vault.AssetsTotal/LoanBroker.DebtTotal deltas match
|
||||
// interestPaid/principalPaid under cash-basis, and cross-check the
|
||||
// amendment-disabled run's deltas against the documented whole-life
|
||||
// formula (AssetsTotal += valueChange; DebtTotal mirrors the loan's own
|
||||
// TotalValueOutstanding delta exactly, since whole-life debt recognition
|
||||
// tracks total loan value).
|
||||
void
|
||||
testCashBasisLoanPay()
|
||||
{
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
using namespace std::chrono_literals;
|
||||
using tp = NetClock::time_point;
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 1'000'000,
|
||||
.debtMax = 0,
|
||||
.coverRateMin = TenthBips32{0},
|
||||
.coverDeposit = 0,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{0}};
|
||||
|
||||
Number const principalRequest{12'000};
|
||||
TenthBips32 const interestRate{percentageToTenthBips(12)};
|
||||
std::uint32_t const paymentTotal = 4;
|
||||
std::uint32_t const paymentInterval = 600;
|
||||
std::uint32_t const gracePeriod = 300;
|
||||
|
||||
struct PaymentDeltas
|
||||
{
|
||||
Number principalPaid;
|
||||
Number assetsTotalDelta;
|
||||
Number debtTotalDelta;
|
||||
Number totalValueDelta;
|
||||
};
|
||||
|
||||
// Sets up a fresh broker + loan, advances time, submits a single
|
||||
// payment of the given type/amount, and returns the observed deltas.
|
||||
auto runPayment = [&](FeatureBitset features,
|
||||
std::uint32_t loanSetFlags,
|
||||
std::uint32_t payFlags,
|
||||
std::function<void(Env&, tp const&)> const& advanceTime,
|
||||
std::function<STAmount(LoanState const&)> const& paymentAmount) {
|
||||
Env env(*this, features);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(10'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
LoanParameters const loanParams{
|
||||
.account = borrower,
|
||||
.counter = lender,
|
||||
.principalRequest = principalRequest,
|
||||
.interest = interestRate,
|
||||
.payTotal = paymentTotal,
|
||||
.payInterval = paymentInterval,
|
||||
.gracePd = gracePeriod,
|
||||
.flags = loanSetFlags,
|
||||
};
|
||||
|
||||
auto const brokerBeforeLoan = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(brokerBeforeLoan);
|
||||
auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence);
|
||||
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
|
||||
|
||||
env(loanParams(env, broker));
|
||||
env.close();
|
||||
|
||||
LoanState const state = getCurrentState(env, broker, loanKeylet);
|
||||
|
||||
advanceTime(env, state.startDate);
|
||||
|
||||
auto const vaultBefore = env.le(broker.vaultKeylet());
|
||||
auto const brokerBefore = env.le(broker.brokerKeylet());
|
||||
auto const loanBefore = env.le(loanKeylet);
|
||||
BEAST_EXPECT(vaultBefore && brokerBefore && loanBefore);
|
||||
|
||||
Number const principalBefore = loanBefore->at(sfPrincipalOutstanding);
|
||||
Number const totalValueBefore = loanBefore->at(sfTotalValueOutstanding);
|
||||
Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
|
||||
Number const debtTotalBefore = brokerBefore->at(sfDebtTotal);
|
||||
|
||||
STAmount const amount = paymentAmount(state);
|
||||
env(pay(borrower, loanKeylet.key, amount, payFlags), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const vaultAfter = env.le(broker.vaultKeylet());
|
||||
auto const brokerAfter = env.le(broker.brokerKeylet());
|
||||
auto const loanAfter = env.le(loanKeylet);
|
||||
BEAST_EXPECT(vaultAfter && brokerAfter && loanAfter);
|
||||
|
||||
Number const principalAfter = loanAfter->at(sfPrincipalOutstanding);
|
||||
Number const totalValueAfter = loanAfter->at(sfTotalValueOutstanding);
|
||||
Number const assetsTotalAfter = vaultAfter->at(sfAssetsTotal);
|
||||
Number const debtTotalAfter = brokerAfter->at(sfDebtTotal);
|
||||
|
||||
return PaymentDeltas{
|
||||
.principalPaid = principalBefore - principalAfter,
|
||||
.assetsTotalDelta = assetsTotalAfter - assetsTotalBefore,
|
||||
.debtTotalDelta = debtTotalAfter - debtTotalBefore,
|
||||
.totalValueDelta = totalValueAfter - totalValueBefore};
|
||||
};
|
||||
|
||||
// Compares the disabled (whole-life) and enabled (cash-basis) runs
|
||||
// of the same payment scenario, and asserts the documented
|
||||
// relationships between them.
|
||||
auto checkScenario = [&](std::string const& label,
|
||||
PaymentDeltas const& off,
|
||||
PaymentDeltas const& on) {
|
||||
testcase("cash-basis: LoanPay " + label);
|
||||
|
||||
// The loan's own PrincipalOutstanding field is untouched by
|
||||
// the amendment.
|
||||
BEAST_EXPECTS(
|
||||
off.principalPaid == on.principalPaid,
|
||||
"principalPaid must be amendment-independent; off=" + to_string(off.principalPaid) +
|
||||
" on=" + to_string(on.principalPaid));
|
||||
|
||||
// Whole-life structural invariant: DebtTotal (which
|
||||
// recognizes a loan's full remaining value as debt) must
|
||||
// change exactly as the loan's own TotalValueOutstanding
|
||||
// does.
|
||||
BEAST_EXPECTS(
|
||||
off.debtTotalDelta == off.totalValueDelta,
|
||||
"whole-life DebtTotal delta must mirror TotalValueOutstanding delta; "
|
||||
"debtTotalDelta=" +
|
||||
to_string(off.debtTotalDelta) +
|
||||
" totalValueDelta=" + to_string(off.totalValueDelta));
|
||||
|
||||
// Derive interestPaid from the whole-life run's independent
|
||||
// ledger deltas:
|
||||
// assetsTotalDelta_off == valueChange
|
||||
// debtTotalDelta_off == valueChange - (principalPaid + interestPaid)
|
||||
// => interestPaid == assetsTotalDelta_off - debtTotalDelta_off - principalPaid
|
||||
Number const interestPaid =
|
||||
off.assetsTotalDelta - off.debtTotalDelta - off.principalPaid;
|
||||
BEAST_EXPECTS(
|
||||
interestPaid >= beast::kZero,
|
||||
"derived interestPaid must be non-negative: " + to_string(interestPaid));
|
||||
|
||||
BEAST_EXPECTS(
|
||||
on.assetsTotalDelta == interestPaid,
|
||||
"cash-basis AssetsTotal delta must equal interestPaid; delta=" +
|
||||
to_string(on.assetsTotalDelta) + " interestPaid=" + to_string(interestPaid));
|
||||
BEAST_EXPECTS(
|
||||
on.debtTotalDelta == -on.principalPaid,
|
||||
"cash-basis DebtTotal delta must equal -principalPaid; delta=" +
|
||||
to_string(on.debtTotalDelta) + " principalPaid=" + to_string(on.principalPaid));
|
||||
};
|
||||
|
||||
// ---- Regular, on-time payment ----
|
||||
{
|
||||
auto const noAdvance = [](Env& env, tp const&) { env.close(); };
|
||||
auto const regularAmount = [&](LoanState const& state) {
|
||||
return STAmount{
|
||||
xrpAsset,
|
||||
roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) *
|
||||
Number{3, -1} * 5}; // 1.5x, so only a single period is paid
|
||||
};
|
||||
|
||||
auto const off = runPayment(all_, 0, 0, noAdvance, regularAmount);
|
||||
auto const on =
|
||||
runPayment(all_ | featureLendingProtocolV1_1, 0, 0, noAdvance, regularAmount);
|
||||
|
||||
// Regular, on-time payments never change the loan's value beyond
|
||||
// normal amortization (production asserts valueChange == 0), so
|
||||
// AssetsTotal must be unaffected in the whole-life run.
|
||||
BEAST_EXPECTS(
|
||||
off.assetsTotalDelta == beast::kZero,
|
||||
"regular on-time payment must not change AssetsTotal under whole-life; delta=" +
|
||||
to_string(off.assetsTotalDelta));
|
||||
|
||||
checkScenario("regular payment", off, on);
|
||||
}
|
||||
|
||||
// ---- Late payment ----
|
||||
{
|
||||
auto const advancePastDue = [&](Env& env, tp const& startDate) {
|
||||
env.close(startDate + std::chrono::seconds(paymentInterval + 1));
|
||||
};
|
||||
auto const lateAmount = [&](LoanState const& state) {
|
||||
return STAmount{
|
||||
xrpAsset,
|
||||
roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) *
|
||||
Number{3}}; // generous; excess is not withdrawn
|
||||
};
|
||||
|
||||
auto const off = runPayment(all_, 0, tfLoanLatePayment, advancePastDue, lateAmount);
|
||||
auto const on = runPayment(
|
||||
all_ | featureLendingProtocolV1_1,
|
||||
0,
|
||||
tfLoanLatePayment,
|
||||
advancePastDue,
|
||||
lateAmount);
|
||||
|
||||
checkScenario("late payment", off, on);
|
||||
}
|
||||
|
||||
// ---- Overpayment ----
|
||||
{
|
||||
auto const noAdvance = [](Env& env, tp const&) { env.close(); };
|
||||
auto const overpayAmount = [&](LoanState const& state) {
|
||||
// One regular period, plus a generous extra principal
|
||||
// paydown.
|
||||
return STAmount{
|
||||
xrpAsset,
|
||||
roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale) +
|
||||
xrpAsset(2'000).value()};
|
||||
};
|
||||
|
||||
auto const off =
|
||||
runPayment(all_, tfLoanOverpayment, tfLoanOverpayment, noAdvance, overpayAmount);
|
||||
auto const on = runPayment(
|
||||
all_ | featureLendingProtocolV1_1,
|
||||
tfLoanOverpayment,
|
||||
tfLoanOverpayment,
|
||||
noAdvance,
|
||||
overpayAmount);
|
||||
|
||||
checkScenario("overpayment", off, on);
|
||||
}
|
||||
|
||||
// ---- Full payment ----
|
||||
{
|
||||
auto const noAdvance = [](Env& env, tp const&) { env.close(); };
|
||||
auto const fullAmount = [&](LoanState const&) {
|
||||
// Generously large: full payment only ever consumes exactly
|
||||
// what's due (principal + accrued interest; close fee/
|
||||
// prepayment penalty are 0 here), excess is not withdrawn.
|
||||
return STAmount{xrpAsset, xrpAsset(principalRequest).value() * Number{2}};
|
||||
};
|
||||
|
||||
auto const off = runPayment(all_, 0, tfLoanFullPayment, noAdvance, fullAmount);
|
||||
auto const on = runPayment(
|
||||
all_ | featureLendingProtocolV1_1, 0, tfLoanFullPayment, noAdvance, fullAmount);
|
||||
|
||||
checkScenario("full payment", off, on);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. LoanManage: impair, unimpair, and default.
|
||||
void
|
||||
testCashBasisLoanManage()
|
||||
{
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 1'000'000,
|
||||
.debtMax = 0,
|
||||
.coverRateMin = TenthBips32{percentageToTenthBips(10)},
|
||||
.coverDeposit = 5'000,
|
||||
.managementFeeRate = TenthBips16{0},
|
||||
.coverRateLiquidation = TenthBips32{percentageToTenthBips(25)}};
|
||||
|
||||
Number const principalRequest{10'000};
|
||||
TenthBips32 const interestRate{percentageToTenthBips(12)};
|
||||
std::uint32_t const paymentTotal = 4;
|
||||
std::uint32_t const paymentInterval = 600;
|
||||
std::uint32_t const gracePeriod = 60;
|
||||
|
||||
auto setupLoan = [&](Env& env) {
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(10'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
LoanParameters const loanParams{
|
||||
.account = borrower,
|
||||
.counter = lender,
|
||||
.principalRequest = principalRequest,
|
||||
.interest = interestRate,
|
||||
.payTotal = paymentTotal,
|
||||
.payInterval = paymentInterval,
|
||||
.gracePd = gracePeriod,
|
||||
};
|
||||
|
||||
auto const brokerBeforeLoan = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(brokerBeforeLoan);
|
||||
auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence);
|
||||
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
|
||||
|
||||
env(loanParams(env, broker));
|
||||
env.close();
|
||||
|
||||
return std::make_tuple(broker, loanKeylet, lender, borrower);
|
||||
};
|
||||
|
||||
// ---- impair / unimpair ----
|
||||
auto runImpairUnimpair = [&](FeatureBitset features) {
|
||||
Env env(*this, features);
|
||||
auto const [broker, loanKeylet, lender, borrower] = setupLoan(env);
|
||||
|
||||
auto const loanBefore = env.le(loanKeylet);
|
||||
BEAST_EXPECT(loanBefore);
|
||||
Number const principalOutstanding = loanBefore->at(sfPrincipalOutstanding);
|
||||
Number const totalValueOutstanding = loanBefore->at(sfTotalValueOutstanding);
|
||||
Number const managementFeeOutstanding = loanBefore->at(sfManagementFeeOutstanding);
|
||||
|
||||
Number const expectedExposure =
|
||||
env.current()->rules().enabled(featureLendingProtocolV1_1)
|
||||
? principalOutstanding
|
||||
: totalValueOutstanding - managementFeeOutstanding;
|
||||
|
||||
auto const vaultBeforeImpair = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultBeforeImpair);
|
||||
Number const lossBefore = vaultBeforeImpair->at(sfLossUnrealized);
|
||||
|
||||
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const vaultAfterImpair = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultAfterImpair);
|
||||
Number const impairDelta = Number(vaultAfterImpair->at(sfLossUnrealized)) - lossBefore;
|
||||
|
||||
env(manage(lender, loanKeylet.key, tfLoanUnimpair), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const vaultAfterUnimpair = env.le(broker.vaultKeylet());
|
||||
BEAST_EXPECT(vaultAfterUnimpair);
|
||||
Number const netDelta = Number(vaultAfterUnimpair->at(sfLossUnrealized)) - lossBefore;
|
||||
|
||||
return std::make_tuple(expectedExposure, impairDelta, netDelta);
|
||||
};
|
||||
|
||||
for (auto const features : {all_ | featureLendingProtocolV1_1, all_})
|
||||
{
|
||||
testcase(
|
||||
std::string("cash-basis: LoanManage impair/unimpair (") +
|
||||
(features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)"));
|
||||
auto const [expectedExposure, impairDelta, netDelta] = runImpairUnimpair(features);
|
||||
|
||||
BEAST_EXPECTS(
|
||||
impairDelta == expectedExposure,
|
||||
"impair must add loanVaultExposure to LossUnrealized; delta=" +
|
||||
to_string(impairDelta) + " expected=" + to_string(expectedExposure));
|
||||
BEAST_EXPECTS(
|
||||
netDelta == beast::kZero,
|
||||
"unimpair must be an exact reversal of impair; net=" + to_string(netDelta));
|
||||
}
|
||||
|
||||
// ---- impair, then default ----
|
||||
auto runDefault = [&](FeatureBitset features) {
|
||||
Env env(*this, features);
|
||||
auto const [broker, loanKeylet, lender, borrower] = setupLoan(env);
|
||||
|
||||
auto const loanBeforeImpair = env.le(loanKeylet);
|
||||
BEAST_EXPECT(loanBeforeImpair);
|
||||
Number const principalOutstanding = loanBeforeImpair->at(sfPrincipalOutstanding);
|
||||
Number const totalValueOutstanding = loanBeforeImpair->at(sfTotalValueOutstanding);
|
||||
Number const managementFeeOutstanding =
|
||||
loanBeforeImpair->at(sfManagementFeeOutstanding);
|
||||
|
||||
Number const expectedExposure =
|
||||
env.current()->rules().enabled(featureLendingProtocolV1_1)
|
||||
? principalOutstanding
|
||||
: totalValueOutstanding - managementFeeOutstanding;
|
||||
|
||||
env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
LoanState const state = getCurrentState(env, broker, loanKeylet);
|
||||
env.close(
|
||||
state.startDate + std::chrono::seconds(paymentInterval) +
|
||||
std::chrono::seconds(gracePeriod) + 60s);
|
||||
|
||||
auto const vaultBefore = env.le(broker.vaultKeylet());
|
||||
auto const brokerBefore = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(vaultBefore && brokerBefore);
|
||||
Number const assetsTotalBefore = vaultBefore->at(sfAssetsTotal);
|
||||
Number const debtTotalBefore = brokerBefore->at(sfDebtTotal);
|
||||
Number const lossBefore = vaultBefore->at(sfLossUnrealized);
|
||||
Number const coverAvailableBefore = brokerBefore->at(sfCoverAvailable);
|
||||
|
||||
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
auto const vaultAfter = env.le(broker.vaultKeylet());
|
||||
auto const brokerAfter = env.le(broker.brokerKeylet());
|
||||
BEAST_EXPECT(vaultAfter && brokerAfter);
|
||||
Number const assetsTotalDelta =
|
||||
Number(vaultAfter->at(sfAssetsTotal)) - assetsTotalBefore;
|
||||
Number const debtTotalDelta = Number(brokerAfter->at(sfDebtTotal)) - debtTotalBefore;
|
||||
Number const lossDelta = Number(vaultAfter->at(sfLossUnrealized)) - lossBefore;
|
||||
Number const coverAvailableDelta =
|
||||
Number(brokerAfter->at(sfCoverAvailable)) - coverAvailableBefore;
|
||||
|
||||
Number const defaultCovered = -coverAvailableDelta;
|
||||
Number const vaultDefaultAmount = expectedExposure - defaultCovered;
|
||||
|
||||
return std::make_tuple(
|
||||
expectedExposure, assetsTotalDelta, debtTotalDelta, lossDelta, vaultDefaultAmount);
|
||||
};
|
||||
|
||||
for (auto const features : {all_ | featureLendingProtocolV1_1, all_})
|
||||
{
|
||||
testcase(
|
||||
std::string("cash-basis: LoanManage default (") +
|
||||
(features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)"));
|
||||
auto const
|
||||
[expectedExposure,
|
||||
assetsTotalDelta,
|
||||
debtTotalDelta,
|
||||
lossDelta,
|
||||
vaultDefaultAmount] = runDefault(features);
|
||||
|
||||
BEAST_EXPECTS(
|
||||
debtTotalDelta == -expectedExposure,
|
||||
"default must reduce DebtTotal by the unified default amount; delta=" +
|
||||
to_string(debtTotalDelta) + " expected=" + to_string(expectedExposure));
|
||||
BEAST_EXPECTS(
|
||||
lossDelta == -expectedExposure,
|
||||
"default must reverse the earlier impair's LossUnrealized exactly; delta=" +
|
||||
to_string(lossDelta) + " expected=" + to_string(expectedExposure));
|
||||
BEAST_EXPECTS(
|
||||
assetsTotalDelta == -vaultDefaultAmount,
|
||||
"default must reduce AssetsTotal by (defaultAmount - defaultCovered); delta=" +
|
||||
to_string(assetsTotalDelta) + " expected=" + to_string(-vaultDefaultAmount));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. End-to-end trajectory: LoanSet -> 2 LoanPays -> LoanManage(default),
|
||||
// entirely under the amendment, with independently hand-computed
|
||||
// expected AssetsTotal/DebtTotal/LossUnrealized/CoverAvailable values at
|
||||
// each step. 0% interest keeps the arithmetic exact and tractable; the
|
||||
// divergence from whole-life accounting is already covered directly by
|
||||
// testCashBasisLoanSetOrigination/LoanPay/LoanManage above, so this test
|
||||
// focuses purely on an independent, from-scratch trajectory check.
|
||||
void
|
||||
testCashBasisEndToEndTrajectory()
|
||||
{
|
||||
testcase("cash-basis: end-to-end trajectory");
|
||||
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1'000'000};
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 100'000, .managementFeeRate = TenthBips16{0}};
|
||||
|
||||
Env env(*this, all_ | featureLendingProtocolV1_1);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
env.fund(XRP(10'000'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
BrokerInfo const broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)};
|
||||
|
||||
// Hand computation (all values in XRP, drops == 1e-6 XRP):
|
||||
// Vault: AssetsTotal starts at 100'000 (the deposit).
|
||||
// Broker: DebtTotal starts at 0, CoverAvailable starts at 1'000
|
||||
// (BrokerParameters::defaults().coverDeposit).
|
||||
auto const vaultKeylet = broker.vaultKeylet();
|
||||
auto const brokerKeylet = broker.brokerKeylet();
|
||||
|
||||
// All the "human XRP unit" constants below (e.g. `100'000`) are
|
||||
// converted to raw native (drops) values via xrpAsset(...), since
|
||||
// that's how the ledger fields are actually denominated.
|
||||
auto const checkVaultBroker = [&](Number const& assetsTotalUnits,
|
||||
Number const& debtTotalUnits,
|
||||
Number const& lossUnrealizedUnits,
|
||||
Number const& coverAvailableUnits,
|
||||
char const* step) {
|
||||
Number const assetsTotal = xrpAsset(assetsTotalUnits).value();
|
||||
Number const debtTotal = xrpAsset(debtTotalUnits).value();
|
||||
Number const lossUnrealized = xrpAsset(lossUnrealizedUnits).value();
|
||||
Number const coverAvailable = xrpAsset(coverAvailableUnits).value();
|
||||
|
||||
auto const vaultSle = env.le(vaultKeylet);
|
||||
auto const brokerSle = env.le(brokerKeylet);
|
||||
BEAST_EXPECT(vaultSle && brokerSle);
|
||||
BEAST_EXPECTS(
|
||||
vaultSle->at(sfAssetsTotal) == assetsTotal,
|
||||
std::string(step) + ": AssetsTotal expected " + to_string(assetsTotal) + " got " +
|
||||
to_string(Number(vaultSle->at(sfAssetsTotal))));
|
||||
BEAST_EXPECTS(
|
||||
brokerSle->at(sfDebtTotal) == debtTotal,
|
||||
std::string(step) + ": DebtTotal expected " + to_string(debtTotal) + " got " +
|
||||
to_string(Number(brokerSle->at(sfDebtTotal))));
|
||||
BEAST_EXPECTS(
|
||||
vaultSle->at(sfLossUnrealized) == lossUnrealized,
|
||||
std::string(step) + ": LossUnrealized expected " + to_string(lossUnrealized) +
|
||||
" got " + to_string(Number(vaultSle->at(sfLossUnrealized))));
|
||||
BEAST_EXPECTS(
|
||||
brokerSle->at(sfCoverAvailable) == coverAvailable,
|
||||
std::string(step) + ": CoverAvailable expected " + to_string(coverAvailable) +
|
||||
" got " + to_string(Number(brokerSle->at(sfCoverAvailable))));
|
||||
};
|
||||
|
||||
checkVaultBroker(100'000, 0, 0, 1'000, "before LoanSet");
|
||||
|
||||
// Loan: principal=1200, 0% interest, 12 payments of 100 each, no fees.
|
||||
Number const principalRequest{1'200};
|
||||
std::uint32_t const paymentTotal = 12;
|
||||
std::uint32_t const paymentInterval = 600;
|
||||
std::uint32_t const gracePeriod = 60;
|
||||
|
||||
auto const brokerBeforeLoan = env.le(brokerKeylet);
|
||||
BEAST_EXPECT(brokerBeforeLoan);
|
||||
auto const loanSequence = brokerBeforeLoan->at(sfLoanSequence);
|
||||
auto const loanKeylet = keylet::loan(broker.brokerID, loanSequence);
|
||||
|
||||
LoanParameters const loanParams{
|
||||
.account = borrower,
|
||||
.counter = lender,
|
||||
.principalRequest = principalRequest,
|
||||
.interest = TenthBips32{0},
|
||||
.payTotal = paymentTotal,
|
||||
.payInterval = paymentInterval,
|
||||
.gracePd = gracePeriod,
|
||||
};
|
||||
env(loanParams(env, broker));
|
||||
env.close();
|
||||
|
||||
// Origination (cash-basis): AssetsTotal += 0, DebtTotal += principal.
|
||||
checkVaultBroker(100'000, 1'200, 0, 1'000, "after LoanSet");
|
||||
|
||||
LoanState const state = getCurrentState(env, broker, loanKeylet);
|
||||
BEAST_EXPECT(state.periodicPayment == xrpAsset(100).value());
|
||||
|
||||
// Payment 1: principalPaid=100, interestPaid=0.
|
||||
// AssetsTotal += 0; DebtTotal -= 100.
|
||||
env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
checkVaultBroker(100'000, 1'100, 0, 1'000, "after payment 1");
|
||||
|
||||
// Payment 2: same as above.
|
||||
env(pay(borrower, loanKeylet.key, xrpAsset(100).value()), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
checkVaultBroker(100'000, 1'000, 0, 1'000, "after payment 2");
|
||||
|
||||
// Default (no impair): principalOutstanding remaining is 1'000.
|
||||
// totalDefaultAmount (cash-basis) = PrincipalOutstanding = 1'000.
|
||||
// minimumCover = DebtTotal(1'000) * coverRateMin(10%) = 100.
|
||||
// covered = min(minimumCover * coverRateLiquidation(25%), totalDefaultAmount)
|
||||
// = min(25, 1'000) = 25.
|
||||
// defaultCovered = min(covered, CoverAvailable(1'000)) = 25.
|
||||
// vaultDefaultAmount = 1'000 - 25 = 975.
|
||||
// DebtTotal -= 1'000 -> 0. CoverAvailable -= 25 -> 975.
|
||||
// AssetsTotal -= 975 -> 99'025. LossUnrealized unaffected (never impaired).
|
||||
auto const loanBeforeDefault = env.le(loanKeylet);
|
||||
BEAST_EXPECT(loanBeforeDefault);
|
||||
BEAST_EXPECT(
|
||||
Number(loanBeforeDefault->at(sfPrincipalOutstanding)) == xrpAsset(1'000).value());
|
||||
|
||||
env.close(state.startDate + std::chrono::seconds((3 * paymentInterval) + gracePeriod) + 1s);
|
||||
|
||||
env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
|
||||
env.close();
|
||||
|
||||
checkVaultBroker(99'025, 0, 0, 975, "after LoanManage(default)");
|
||||
}
|
||||
|
||||
void
|
||||
runAmendmentIndependent()
|
||||
{
|
||||
@@ -8570,6 +9347,11 @@ protected:
|
||||
testBugInterestDueDeltaCrash();
|
||||
testFullLifecycleVaultPnLNearZeroRate();
|
||||
testLoanSetNearZeroInterestRateSucceeds();
|
||||
|
||||
testCashBasisLoanSetOrigination();
|
||||
testCashBasisLoanPay();
|
||||
testCashBasisLoanManage();
|
||||
testCashBasisEndToEndTrajectory();
|
||||
}
|
||||
|
||||
// Tests run under each entry in amendmentCombinations().
|
||||
|
||||
Reference in New Issue
Block a user