Compare commits

...

1 Commits

Author SHA1 Message Date
Vito
c1a0f94c2e feat: Implement LendingProtocolV1_1 cash-basis accounting
Gate Vault AssetsTotal and LoanBroker DebtTotal to principal-only
tracking under featureLendingProtocolV1_1, replacing whole-life
(accrual) interest recognition. Adds Accrual/CashBasis namespaces and
three public dispatchers in LendingHelpers, consumed by LoanSet,
LoanPay, and LoanManage without any amendment-awareness in the
transactors themselves. Pre-amendment behavior is unchanged.
2026-07-16 18:12:42 +02:00
7 changed files with 1159 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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