From c1a0f94c2ea8a41651645cb921e0a1acd13ef46e Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:12:42 +0200 Subject: [PATCH 01/12] 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. --- include/xrpl/ledger/helpers/LendingHelpers.h | 55 ++ src/libxrpl/ledger/helpers/LendingHelpers.cpp | 71 ++ .../tx/transactors/lending/LoanManage.cpp | 23 +- .../tx/transactors/lending/LoanPay.cpp | 43 +- .../tx/transactors/lending/LoanSet.cpp | 6 +- src/test/app/LendingHelpers_test.cpp | 218 +++++ src/test/app/Loan_test.cpp | 794 +++++++++++++++++- 7 files changed, 1159 insertions(+), 51 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index e2605e9ab7..c6047ec9fb 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -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 diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index f7ec8a8bc3..c9736fb4ca 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -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 diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index a0aa948876..20f3b79ae5 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -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 diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 54ee85b186..da35ebee42 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -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 } diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 694d01c69f..34be342dd8 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -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 diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index ac8e0764fc..af1c229495 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -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{ + {.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 + makeLoanSle( + Number const& totalValueOutstanding, + Number const& principalOutstanding, + Number const& managementFeeOutstanding) + { + auto sle = std::make_shared(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(); } }; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 371fcae54f..79390dce35 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -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 const& advanceTime, + std::function 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(). From cc03abd79bf82718e806727dbb29bb0f147f720e Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:25:25 +0200 Subject: [PATCH 02/12] adds LEVersion field to Vault --- include/xrpl/ledger/helpers/LendingHelpers.h | 15 +- include/xrpl/ledger/helpers/VaultHelpers.h | 14 ++ include/xrpl/protocol/Protocol.h | 12 + .../xrpl/protocol/detail/ledger_entries.macro | 1 + include/xrpl/protocol/detail/sfields.macro | 1 + src/libxrpl/ledger/helpers/LendingHelpers.cpp | 39 +++- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 19 ++ .../tx/transactors/lending/LoanManage.cpp | 6 +- .../tx/transactors/lending/LoanPay.cpp | 3 +- .../tx/transactors/lending/LoanSet.cpp | 2 +- .../tx/transactors/vault/VaultCreate.cpp | 3 + src/test/app/LendingHelpers_test.cpp | 86 +++++-- src/test/app/Loan_test.cpp | 218 +++++++++++++++++- src/test/app/Vault_test.cpp | 76 ++++++ 14 files changed, 450 insertions(+), 45 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 9008c528a5..6e1299687b 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -304,7 +304,7 @@ loanOriginationDeltas(Number const& principalRequested, Number const& interestDu // LoanManage impair/unimpair/default: the vault's exposure to this loan Number -loanVaultExposure(SLE::ref loanSle); +loanVaultExposure(SLE::const_ref loanSle); // LoanPay: what's added to Vault.AssetsTotal and subtracted from LoanBroker.DebtTotal for a payment AccountingDeltas @@ -317,10 +317,10 @@ loanPaymentDeltas(LoanPaymentParts const& parts); namespace CashBasis { AccountingDeltas -loanOriginationDeltas(Number const& principalRequested, Number const& interestDue); +loanOriginationDeltas(Number const& principalRequested); Number -loanVaultExposure(SLE::ref loanSle); +loanVaultExposure(SLE::const_ref loanSle); AccountingDeltas loanPaymentDeltas(LoanPaymentParts const& parts); @@ -328,18 +328,21 @@ 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. +// enabled AND the Vault's LEVersion (VaultHelpers::getVaultVersion) is +// VaultVersion::CashBasis, else Accrual::. These are the only entry points +// transactors call. AccountingDeltas loanOriginationDeltas( Rules const& rules, + SLE::const_ref vaultSle, Number const& principalRequested, Number const& interestDue); Number -loanVaultExposure(Rules const& rules, SLE::ref loanSle); +loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle); AccountingDeltas -loanPaymentDeltas(Rules const& rules, LoanPaymentParts const& parts); +loanPaymentDeltas(Rules const& rules, SLE::const_ref vaultSle, LoanPaymentParts const& parts); namespace detail { // These classes and functions should only be accessed by LendingHelper diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 1bd1663314..e0101bd742 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -107,4 +108,17 @@ sharesToAssetsWithdraw( [[nodiscard]] bool isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref issuance); +/** + * Resolves a Vault's LEVersion, the single point every accounting touch + * point should call to determine which recognition model (accrual vs. + * cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 + * activated never have sfLEVersion set, which resolves here to 0 (legacy). + * + * @param vault The vault SLE. + * + * @return The Vault's LEVersion, or 0 if the field is absent. + */ +[[nodiscard]] VaultVersion +getVaultVersion(SLE::const_ref vault); + } // namespace xrpl diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index e83e1c97b6..f387ea56de 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -316,6 +316,18 @@ constexpr std::uint8_t kVaultDefaultIouScale = 6; */ constexpr std::uint8_t kVaultMaximumIouScale = 18; +/** + * Vault ledger-entry schema versions. Assigned to newly created + * Vaults once featureLendingProtocolV1_1 is enabled. Vaults created before + * activation are left without LEVersion (implicit legacy version 0, + * accrual-basis accounting). + */ +enum class VaultVersion : uint8_t { + Legacy = 0, + Invalid, + CashBasis, +}; + /** * Maximum recursion depth for vault shares being put as an asset inside * another vault; counted from 0 diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 90810e06d2..b6408581a9 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -505,6 +505,7 @@ LEDGER_ENTRY(ltVAULT, 0x0084, Vault, vault, ({ {sfShareMPTID, SoeRequired}, {sfWithdrawalPolicy, SoeRequired}, {sfScale, SoeDefault}, + {sfLEVersion, SoeDefault}, // no SharesTotal ever (use MPTIssuance.sfOutstandingAmount) // no PermissionedDomainID ever (use MPTIssuance.sfDomainID) })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 4ef76c8b75..16defe3ba3 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -18,6 +18,7 @@ TYPED_SFIELD(sfMethod, UINT8, 2) TYPED_SFIELD(sfTransactionResult, UINT8, 3) TYPED_SFIELD(sfScale, UINT8, 4) TYPED_SFIELD(sfAssetScale, UINT8, 5) +TYPED_SFIELD(sfLEVersion, UINT8, 6) // 8-bit integers (uncommon) TYPED_SFIELD(sfTickSize, UINT8, 16) diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 1bc8e3bfe4..988c9cbcc1 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -139,7 +140,7 @@ loanOriginationDeltas(Number const& principalRequested, Number const& interestDu } Number -loanVaultExposure(SLE::ref loanSle) +loanVaultExposure(SLE::const_ref loanSle) { return loanSle->at(sfTotalValueOutstanding) - loanSle->at(sfManagementFeeOutstanding); } @@ -157,13 +158,13 @@ loanPaymentDeltas(LoanPaymentParts const& parts) namespace CashBasis { AccountingDeltas -loanOriginationDeltas(Number const& principalRequested, Number const&) +loanOriginationDeltas(Number const& principalRequested) { return {.assetsTotalDelta = kNumZero, .debtTotalDelta = principalRequested}; } Number -loanVaultExposure(SLE::ref loanSle) +loanVaultExposure(SLE::const_ref loanSle) { return loanSle->at(sfPrincipalOutstanding); } @@ -176,29 +177,45 @@ loanPaymentDeltas(LoanPaymentParts const& parts) } // namespace CashBasis +namespace { + +// Cash-basis accounting applies only when featureLendingProtocolV1_1 is +// enabled AND the specific Vault was created under it (LEVersion == +// VaultVersion::CashBasis). Vaults created before activation keep accrual-basis +// accounting forever, even after the amendment later turns on. +bool +cashBasisEnabled(Rules const& rules, SLE::const_ref vaultSle) +{ + return rules.enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis; +} + +} // namespace + AccountingDeltas loanOriginationDeltas( Rules const& rules, + SLE::const_ref vaultSle, Number const& principalRequested, Number const& interestDue) { - return rules.enabled(featureLendingProtocolV1_1) - ? CashBasis::loanOriginationDeltas(principalRequested, interestDue) + return cashBasisEnabled(rules, vaultSle) + ? CashBasis::loanOriginationDeltas(principalRequested) : Accrual::loanOriginationDeltas(principalRequested, interestDue); } Number -loanVaultExposure(Rules const& rules, SLE::ref loanSle) +loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle) { - return rules.enabled(featureLendingProtocolV1_1) ? CashBasis::loanVaultExposure(loanSle) - : Accrual::loanVaultExposure(loanSle); + return cashBasisEnabled(rules, vaultSle) ? CashBasis::loanVaultExposure(loanSle) + : Accrual::loanVaultExposure(loanSle); } AccountingDeltas -loanPaymentDeltas(Rules const& rules, LoanPaymentParts const& parts) +loanPaymentDeltas(Rules const& rules, SLE::const_ref vaultSle, LoanPaymentParts const& parts) { - return rules.enabled(featureLendingProtocolV1_1) ? CashBasis::loanPaymentDeltas(parts) - : Accrual::loanPaymentDeltas(parts); + return cashBasisEnabled(rules, vaultSle) ? CashBasis::loanPaymentDeltas(parts) + : Accrual::loanPaymentDeltas(parts); } namespace detail { diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index b5b076d1cb..5b2694250d 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -6,6 +6,7 @@ #include #include #include // IWYU pragma: keep +#include #include #include #include @@ -137,4 +138,22 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref return sleToken->getFieldU64(sfMPTAmount) == outstanding; } +[[nodiscard]] VaultVersion +getVaultVersion(SLE::const_ref vault) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::getVaultVersion : valid Vault sle"); + if (!vault->isFieldPresent(sfLEVersion)) + return VaultVersion::Legacy; + + switch (vault->at(sfLEVersion)) + { + case 0: + return VaultVersion::Legacy; + case 2: + return VaultVersion::CashBasis; + default: + return VaultVersion::Invalid; + } +} + } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index 20f3b79ae5..5c71843129 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -141,7 +141,7 @@ LoanManage::defaultLoan( std::int32_t const loanScale = loanSle->at(sfLoanScale); auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); - Number const totalDefaultAmount = loanVaultExposure(view.rules(), loanSle); + Number const totalDefaultAmount = loanVaultExposure(view.rules(), vaultSle, loanSle); // Apply the First-Loss Capital to the Default Amount TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; @@ -287,7 +287,7 @@ LoanManage::impairLoan( Asset const& vaultAsset, beast::Journal j) { - Number const lossUnrealized = loanVaultExposure(view.rules(), loanSle); + Number const lossUnrealized = loanVaultExposure(view.rules(), vaultSle, 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 @@ -336,7 +336,7 @@ LoanManage::unimpairLoan( // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - Number const lossReversed = loanVaultExposure(view.rules(), loanSle); + Number const lossReversed = loanVaultExposure(view.rules(), vaultSle, loanSle); if (vaultLossUnrealizedProxy < lossReversed) { // LCOV_EXCL_START diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index da35ebee42..6566a7155d 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -420,7 +420,8 @@ LoanPay::doApply() // LCOV_EXCL_STOP } - auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(view.rules(), *paymentParts); + auto const [assetsTotalDelta, debtTotalDelta] = + loanPaymentDeltas(view.rules(), vaultSle, *paymentParts); JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid << ", interest paid: " << paymentParts->interestPaid diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 34be342dd8..52d3361db7 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -634,7 +634,7 @@ LoanSet::doApply() // Update the balances in the vault auto const [assetsTotalDelta, debtTotalDelta] = - loanOriginationDeltas(ctx_.view().rules(), principalRequested, state.interestDue); + loanOriginationDeltas(ctx_.view().rules(), vaultSle, principalRequested, state.interestDue); vaultAvailableProxy -= principalRequested; vaultTotalProxy += assetsTotalDelta; XRPL_ASSERT_PARTS( diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index e1f5873a89..a522f62788 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -30,6 +30,7 @@ #include #include #include +#include namespace xrpl { @@ -241,6 +242,8 @@ VaultCreate::doApply() } if (scale != 0u) vault->at(sfScale) = scale; + if (view().rules().enabled(featureLendingProtocolV1_1)) + vault->at(sfLEVersion) = std::to_underlying(VaultVersion::CashBasis); view().insert(vault); // Explicitly create MPToken for the vault owner diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index af1c229495..b27c976646 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include #include #include +#include #include namespace xrpl::test { @@ -1518,7 +1520,7 @@ class LendingHelpers_test : public beast::unit_test::Suite Number const principalRequested{1'000}; Number const interestDue{75}; - auto const deltas = loanOriginationDeltas(principalRequested, interestDue); + auto const deltas = loanOriginationDeltas(principalRequested); BEAST_EXPECTS( deltas.assetsTotalDelta == 0, "assetsTotalDelta mismatch: expected 0, got " + to_string(deltas.assetsTotalDelta)); @@ -1544,6 +1546,17 @@ class LendingHelpers_test : public beast::unit_test::Suite return sle; } + // Constructs a minimal ltVAULT SLE with just LEVersion set (or left + // absent), for exercising the dispatchers' per-Vault gating. + static std::shared_ptr + makeVaultSle(std::optional leVersion = std::nullopt) + { + auto sle = std::make_shared(ltVAULT, uint256{2u}); + if (leVersion) + sle->at(sfLEVersion) = std::to_underlying(*leVersion); + return sle; + } + void testAccrualLoanVaultExposure() { @@ -1599,12 +1612,15 @@ class LendingHelpers_test : public beast::unit_test::Suite Number const principalRequested{1'000}; Number const interestDue{75}; + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + { 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 deltas = loanOriginationDeltas( + env.current()->rules(), cashBasisVault, principalRequested, interestDue); auto const expected = xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); @@ -1612,12 +1628,26 @@ class LendingHelpers_test : public beast::unit_test::Suite } { - testcase("loanOriginationDeltas dispatcher: amendment enabled picks CashBasis"); + testcase( + "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks " + "Accrual"); Env const env{*this}; - auto const deltas = - loanOriginationDeltas(env.current()->rules(), principalRequested, interestDue); + auto const deltas = loanOriginationDeltas( + env.current()->rules(), legacyVault, principalRequested, interestDue); auto const expected = - xrpl::CashBasis::loanOriginationDeltas(principalRequested, interestDue); + xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue); + BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); + BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); + } + + { + testcase( + "loanOriginationDeltas dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis picks CashBasis"); + Env const env{*this}; + auto const deltas = loanOriginationDeltas( + env.current()->rules(), cashBasisVault, principalRequested, interestDue); + auto const expected = xrpl::CashBasis::loanOriginationDeltas(principalRequested); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); } @@ -1628,22 +1658,37 @@ class LendingHelpers_test : public beast::unit_test::Suite { using namespace jtx; + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + { 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) == + loanVaultExposure(env.current()->rules(), cashBasisVault, sle) == xrpl::Accrual::loanVaultExposure(sle)); } { - testcase("loanVaultExposure dispatcher: amendment enabled picks CashBasis"); + testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual"); Env const env{*this}; auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); BEAST_EXPECT( - loanVaultExposure(env.current()->rules(), sle) == + loanVaultExposure(env.current()->rules(), legacyVault, sle) == + xrpl::Accrual::loanVaultExposure(sle)); + } + + { + testcase( + "loanVaultExposure dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis " + "picks CashBasis"); + Env const env{*this}; + auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); + BEAST_EXPECT( + loanVaultExposure(env.current()->rules(), cashBasisVault, sle) == xrpl::CashBasis::loanVaultExposure(sle)); } } @@ -1659,20 +1704,35 @@ class LendingHelpers_test : public beast::unit_test::Suite .valueChange = Number{5}, .feePaid = Number{3}}; + auto const legacyVault = makeVaultSle(); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); + { testcase("loanPaymentDeltas dispatcher: amendment disabled picks Accrual"); Env env{*this}; env.disableFeature(featureLendingProtocolV1_1); - auto const deltas = loanPaymentDeltas(env.current()->rules(), parts); + auto const deltas = loanPaymentDeltas(env.current()->rules(), cashBasisVault, 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"); + testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual"); Env const env{*this}; - auto const deltas = loanPaymentDeltas(env.current()->rules(), parts); + auto const deltas = loanPaymentDeltas(env.current()->rules(), legacyVault, 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, LEVersion == " + "VaultVersion::CashBasis " + "picks CashBasis"); + Env const env{*this}; + auto const deltas = loanPaymentDeltas(env.current()->rules(), cashBasisVault, parts); auto const expected = xrpl::CashBasis::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 96ec716c7f..3d124b9dc4 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -368,18 +369,21 @@ protected: { TenthBips16 const managementFeeRate{brokerSle->at(sfManagementFeeRate)}; auto const brokerDebt = brokerSle->at(sfDebtTotal); - 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() == - brokerSle->at(sfCoverAvailable)); - env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); if (auto vaultSle = env.le(keylet::vault(brokerSle->at(sfVaultID))); env.test.BEAST_EXPECT(vaultSle)) { + auto const expectedDebt = + env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis + ? principalOutstanding + : principalOutstanding + interestOwed; + env.test.BEAST_EXPECT(brokerDebt == expectedDebt); + env.test.BEAST_EXPECT( + env.balance(pseudoAccount, broker.asset).number() == + brokerSle->at(sfCoverAvailable)); + env.test.BEAST_EXPECT(brokerSle->at(sfOwnerCount) == ownerCount); + Account const vaultPseudo{"vaultPseudoAccount", vaultSle->at(sfAccount)}; env.test.BEAST_EXPECT( vaultSle->at(sfAssetsAvailable) == @@ -475,7 +479,8 @@ protected: { env.test.BEAST_EXPECT( vaultSle->at(sfLossUnrealized) == - (env.current()->rules().enabled(featureLendingProtocolV1_1) + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis ? principalOutstanding : totalValue - managementFeeOutstanding)); } @@ -645,7 +650,8 @@ protected: auto const assetsUnavailable = vaultSle->at(sfAssetsTotal) - vaultSle->at(sfAssetsAvailable); auto const unrealizedLoss = vaultSle->at(sfLossUnrealized) + - (env.current()->rules().enabled(featureLendingProtocolV1_1) + (env.current()->rules().enabled(featureLendingProtocolV1_1) && + getVaultVersion(vaultSle) == VaultVersion::CashBasis ? state.principalOutstanding : state.totalValue - state.managementFeeOutstanding); @@ -9190,6 +9196,197 @@ protected: } } + // 3b. LEVersion regression: a Vault created before featureLendingProtocolV1_1 + // activates (LEVersion absent) must keep whole-life (accrual) accounting + // forever, even after the amendment is later enabled -- the switch is + // per-Vault (LEVersion == VaultVersion::CashBasis), not a single global amendment + // flag. + void + testLegacyVaultKeepsAccrualAfterAmendmentEnabled() + { + testcase("LEVersion: legacy vault keeps accrual after amendment enabled"); + + 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; + + // Amendment disabled at Vault creation time: LEVersion stays absent. + Env env(*this, all_); + + 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)}; + + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + } + + // Now enable the amendment -- production dispatch must still treat + // this specific Vault as accrual-basis, since its LEVersion is + // (and remains) absent. + env.enableFeature(featureLendingProtocolV1_1); + env.close(); + + 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); + + // ---- LoanSet origination: whole-life formulas expected ---- + auto const vaultBeforeSet = env.le(broker.vaultKeylet()); + auto const brokerBeforeSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeSet && brokerBeforeSet); + Number const assetsTotalBeforeSet = vaultBeforeSet->at(sfAssetsTotal); + Number const debtTotalBeforeSet = brokerBeforeSet->at(sfDebtTotal); + + env(loanParams(env, broker)); + env.close(); + + auto const loanAfterSet = env.le(loanKeylet); + BEAST_EXPECT(loanAfterSet); + Number const principalOutstanding = loanAfterSet->at(sfPrincipalOutstanding); + Number const totalValueOutstanding = loanAfterSet->at(sfTotalValueOutstanding); + Number const interestDue = totalValueOutstanding - principalOutstanding; + BEAST_EXPECT(interestDue > beast::kZero); + + auto const vaultAfterSet = env.le(broker.vaultKeylet()); + auto const brokerAfterSet = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterSet && brokerAfterSet); + Number const assetsTotalDeltaSet = + Number(vaultAfterSet->at(sfAssetsTotal)) - assetsTotalBeforeSet; + Number const debtTotalDeltaSet = + Number(brokerAfterSet->at(sfDebtTotal)) - debtTotalBeforeSet; + + BEAST_EXPECTS( + assetsTotalDeltaSet == interestDue, + "legacy vault origination must still add interestDue to AssetsTotal; delta=" + + to_string(assetsTotalDeltaSet) + " interestDue=" + to_string(interestDue)); + BEAST_EXPECTS( + debtTotalDeltaSet == principalOutstanding + interestDue, + "legacy vault origination must still add principal+interest to DebtTotal; delta=" + + to_string(debtTotalDeltaSet)); + + LoanState const state = getCurrentState(env, broker, loanKeylet); + env.close(); + + // ---- LoanPay: whole-life formulas expected ---- + auto const vaultBeforePay = env.le(broker.vaultKeylet()); + auto const brokerBeforePay = env.le(broker.brokerKeylet()); + auto const loanBeforePay = env.le(loanKeylet); + BEAST_EXPECT(vaultBeforePay && brokerBeforePay && loanBeforePay); + Number const totalValueBeforePay = loanBeforePay->at(sfTotalValueOutstanding); + Number const assetsTotalBeforePay = vaultBeforePay->at(sfAssetsTotal); + Number const debtTotalBeforePay = brokerBeforePay->at(sfDebtTotal); + + STAmount const paymentAmount{ + xrpAsset, roundPeriodicPayment(xrpAsset, state.periodicPayment, state.loanScale)}; + env(pay(borrower, loanKeylet.key, paymentAmount), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterPay = env.le(broker.vaultKeylet()); + auto const brokerAfterPay = env.le(broker.brokerKeylet()); + auto const loanAfterPay = env.le(loanKeylet); + BEAST_EXPECT(vaultAfterPay && brokerAfterPay && loanAfterPay); + Number const totalValueAfterPay = loanAfterPay->at(sfTotalValueOutstanding); + Number const assetsTotalDeltaPay = + Number(vaultAfterPay->at(sfAssetsTotal)) - assetsTotalBeforePay; + Number const debtTotalDeltaPay = + Number(brokerAfterPay->at(sfDebtTotal)) - debtTotalBeforePay; + Number const totalValueDeltaPay = totalValueAfterPay - totalValueBeforePay; + + // A regular, on-time payment has valueChange == 0, so whole-life + // AssetsTotal is untouched and DebtTotal mirrors TotalValueOutstanding. + BEAST_EXPECTS( + assetsTotalDeltaPay == beast::kZero, + "legacy vault regular payment must not change AssetsTotal; delta=" + + to_string(assetsTotalDeltaPay)); + BEAST_EXPECTS( + debtTotalDeltaPay == totalValueDeltaPay, + "legacy vault DebtTotal delta must mirror TotalValueOutstanding delta; " + "debtTotalDelta=" + + to_string(debtTotalDeltaPay) + " totalValueDelta=" + to_string(totalValueDeltaPay)); + + // ---- LoanManage: impair, then default -- whole-life exposure expected ---- + auto const loanBeforeImpair = env.le(loanKeylet); + BEAST_EXPECT(loanBeforeImpair); + Number const totalValueBeforeImpair = loanBeforeImpair->at(sfTotalValueOutstanding); + Number const managementFeeBeforeImpair = loanBeforeImpair->at(sfManagementFeeOutstanding); + Number const expectedExposure = totalValueBeforeImpair - managementFeeBeforeImpair; + + env(manage(lender, loanKeylet.key, tfLoanImpair), Ter(tesSUCCESS)); + env.close(); + + LoanState const stateAtImpair = getCurrentState(env, broker, loanKeylet); + env.close( + stateAtImpair.startDate + std::chrono::seconds(paymentInterval) + + std::chrono::seconds(gracePeriod) + 60s); + + auto const vaultBeforeDefault = env.le(broker.vaultKeylet()); + auto const brokerBeforeDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultBeforeDefault && brokerBeforeDefault); + Number const debtTotalBeforeDefault = brokerBeforeDefault->at(sfDebtTotal); + Number const lossBeforeDefault = vaultBeforeDefault->at(sfLossUnrealized); + + env(manage(lender, loanKeylet.key, tfLoanDefault), Ter(tesSUCCESS)); + env.close(); + + auto const vaultAfterDefault = env.le(broker.vaultKeylet()); + auto const brokerAfterDefault = env.le(broker.brokerKeylet()); + BEAST_EXPECT(vaultAfterDefault && brokerAfterDefault); + Number const debtTotalDeltaDefault = + Number(brokerAfterDefault->at(sfDebtTotal)) - debtTotalBeforeDefault; + Number const lossDeltaDefault = + Number(vaultAfterDefault->at(sfLossUnrealized)) - lossBeforeDefault; + + BEAST_EXPECTS( + debtTotalDeltaDefault == -expectedExposure, + "legacy vault default must reduce DebtTotal by whole-life exposure; delta=" + + to_string(debtTotalDeltaDefault) + " expected=" + to_string(expectedExposure)); + BEAST_EXPECTS( + lossDeltaDefault == -expectedExposure, + "legacy vault default must reverse the earlier impair's LossUnrealized exactly; " + "delta=" + + to_string(lossDeltaDefault) + " expected=" + to_string(expectedExposure)); + + // Confirm the Vault's LEVersion truly never got set, throughout. + { + auto const vaultSle = env.le(broker.vaultKeylet()); + BEAST_EXPECT(vaultSle); + BEAST_EXPECT(!vaultSle->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(getVaultVersion(vaultSle) == VaultVersion::Legacy); + } + } + // 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 @@ -9351,6 +9548,7 @@ protected: testCashBasisLoanSetOrigination(); testCashBasisLoanPay(); testCashBasisLoanManage(); + testLegacyVaultKeepsAccrualAfterAmendmentEnabled(); testCashBasisEndToEndTrajectory(); } diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 617820c89c..0fbfd1a52f 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -7646,6 +7646,81 @@ class Vault_test : public beast::unit_test::Suite } } + void + testVaultCreateLEVersion() + { + using namespace test::jtx; + + Account const owner{"owner"}; + PrettyAsset const xrpAsset = xrpIssue(); + + { + testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 disabled, field absent"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT(!sleVault->isFieldPresent(sfLEVersion)); + } + + { + testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == 2"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(tx, Ter(tesSUCCESS)); + env.close(); + + auto const sleVault = env.le(keylet); + BEAST_EXPECT(sleVault); + BEAST_EXPECT(sleVault->isFieldPresent(sfLEVersion)); + BEAST_EXPECT(sleVault->at(sfLEVersion) == std::to_underlying(VaultVersion::CashBasis)); + } + + { + testcase("VaultCreate rejects LEVersion set in the transaction"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto [tx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + tx[sfLEVersion] = 2; + env(tx, Ter(temMALFORMED)); + env.close(); + + BEAST_EXPECT(!env.le(keylet)); + } + + { + testcase("VaultSet rejects LEVersion set in the transaction"); + Env env{*this}; + env.fund(XRP(1'000'000), owner); + env.close(); + + Vault const vault{env}; + auto const [createTx, keylet] = vault.create({.owner = owner, .asset = xrpAsset}); + env(createTx, Ter(tesSUCCESS)); + env.close(); + + auto setTx = vault.set({.owner = owner, .id = keylet.key}); + setTx[sfLEVersion] = 2; + env(setTx, Ter(temMALFORMED)); + env.close(); + } + } + void testVaultDepositFreezeIOU() { @@ -8318,6 +8393,7 @@ public: testVaultEscrowedMPT(); testAssetsMaximum(); testVaultDeleteMemoData(); + testVaultCreateLEVersion(); testBug6LimitBypassWithShares(); testRemoveEmptyHoldingLockedAmount(); testRemoveEmptyHoldingConfidentialBalances(); From e5d6f256cdabaece16c68dd41148885567ff5da2 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:21:57 +0200 Subject: [PATCH 03/12] fix: autogen files --- .../protocol_autogen/ledger_entries/Vault.h | 35 +++++++++++++++++++ .../ledger_entries/VaultTests.cpp | 27 ++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/include/xrpl/protocol_autogen/ledger_entries/Vault.h b/include/xrpl/protocol_autogen/ledger_entries/Vault.h index 2bf92b4f5d..a6ab54cb0a 100644 --- a/include/xrpl/protocol_autogen/ledger_entries/Vault.h +++ b/include/xrpl/protocol_autogen/ledger_entries/Vault.h @@ -287,6 +287,30 @@ public: { return this->sle_->isFieldPresent(sfScale); } + + /** + * @brief Get sfLEVersion (SoeDefault) + * @return The field value, or std::nullopt if not present. + */ + [[nodiscard]] + protocol_autogen::Optional + getLEVersion() const + { + if (hasLEVersion()) + return this->sle_->at(sfLEVersion); + return std::nullopt; + } + + /** + * @brief Check if sfLEVersion is present. + * @return True if the field is present, false otherwise. + */ + [[nodiscard]] + bool + hasLEVersion() const + { + return this->sle_->isFieldPresent(sfLEVersion); + } }; /** @@ -508,6 +532,17 @@ public: return *this; } + /** + * @brief Set sfLEVersion (SoeDefault) + * @return Reference to this builder for method chaining. + */ + VaultBuilder& + setLEVersion(std::decay_t const& value) + { + object_[sfLEVersion] = value; + return *this; + } + /** * @brief Build and return the completed Vault wrapper. * @param index The ledger entry index. diff --git a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp index 2697924d37..f55d01f606 100644 --- a/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp +++ b/src/tests/libxrpl/protocol_autogen/ledger_entries/VaultTests.cpp @@ -35,6 +35,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); + auto const lEVersionValue = canonical_UINT8(); VaultBuilder builder{ previousTxnIDValue, @@ -54,6 +55,7 @@ TEST(VaultTests, BuilderSettersRoundTrip) builder.setAssetsMaximum(assetsMaximumValue); builder.setLossUnrealized(lossUnrealizedValue); builder.setScale(scaleValue); + builder.setLEVersion(lEVersionValue); builder.setLedgerIndex(index); builder.setFlags(0x1u); @@ -166,6 +168,14 @@ TEST(VaultTests, BuilderSettersRoundTrip) EXPECT_TRUE(entry.hasScale()); } + { + auto const& expected = lEVersionValue; + auto const actualOpt = entry.getLEVersion(); + ASSERT_TRUE(actualOpt.has_value()); + expectEqualField(expected, *actualOpt, "sfLEVersion"); + EXPECT_TRUE(entry.hasLEVersion()); + } + EXPECT_TRUE(entry.hasLedgerIndex()); auto const ledgerIndex = entry.getLedgerIndex(); ASSERT_TRUE(ledgerIndex.has_value()); @@ -194,6 +204,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) auto const shareMPTIDValue = canonical_UINT192(); auto const withdrawalPolicyValue = canonical_UINT8(); auto const scaleValue = canonical_UINT8(); + auto const lEVersionValue = canonical_UINT8(); auto sle = std::make_shared(Vault::entryType, index); @@ -212,6 +223,7 @@ TEST(VaultTests, BuilderFromSleRoundTrip) sle->at(sfShareMPTID) = shareMPTIDValue; sle->at(sfWithdrawalPolicy) = withdrawalPolicyValue; sle->at(sfScale) = scaleValue; + sle->at(sfLEVersion) = lEVersionValue; VaultBuilder builderFromSle{sle}; EXPECT_TRUE(builderFromSle.validate()); @@ -390,6 +402,19 @@ TEST(VaultTests, BuilderFromSleRoundTrip) expectEqualField(expected, *fromBuilderOpt, "sfScale"); } + { + auto const& expected = lEVersionValue; + + auto const fromSleOpt = entryFromSle.getLEVersion(); + auto const fromBuilderOpt = entryFromBuilder.getLEVersion(); + + ASSERT_TRUE(fromSleOpt.has_value()); + ASSERT_TRUE(fromBuilderOpt.has_value()); + + expectEqualField(expected, *fromSleOpt, "sfLEVersion"); + expectEqualField(expected, *fromBuilderOpt, "sfLEVersion"); + } + EXPECT_EQ(entryFromSle.getKey(), index); EXPECT_EQ(entryFromBuilder.getKey(), index); } @@ -472,5 +497,7 @@ TEST(VaultTests, OptionalFieldsReturnNullopt) EXPECT_FALSE(entry.getLossUnrealized().has_value()); EXPECT_FALSE(entry.hasScale()); EXPECT_FALSE(entry.getScale().has_value()); + EXPECT_FALSE(entry.hasLEVersion()); + EXPECT_FALSE(entry.getLEVersion().has_value()); } } From fac3dfccc873d85937b2e7da9c459d83dcbb5078 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:58:17 +0200 Subject: [PATCH 04/12] fix: VaultVersion --- include/xrpl/protocol/Protocol.h | 1 - src/libxrpl/ledger/helpers/VaultHelpers.cpp | 7 +++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/include/xrpl/protocol/Protocol.h b/include/xrpl/protocol/Protocol.h index f387ea56de..9938a9b768 100644 --- a/include/xrpl/protocol/Protocol.h +++ b/include/xrpl/protocol/Protocol.h @@ -324,7 +324,6 @@ constexpr std::uint8_t kVaultMaximumIouScale = 18; */ enum class VaultVersion : uint8_t { Legacy = 0, - Invalid, CashBasis, }; diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 5b2694250d..1ac4abdca3 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -149,10 +149,13 @@ getVaultVersion(SLE::const_ref vault) { case 0: return VaultVersion::Legacy; - case 2: + case 1: return VaultVersion::CashBasis; default: - return VaultVersion::Invalid; + // LCOV_EXCL_START + UNREACHABLE("xrpl::getVaultVersion : invalid vault version"); + return VaultVersion::Legacy; + // LCOV_EXCL_STOP } } From 209ee243d3eb7d4f28ab7af9e1c00309f7221210 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:58:37 +0200 Subject: [PATCH 05/12] fix: Address PR review comments on LEVersion naming Reference VaultVersion::Legacy instead of raw 0 in getVaultVersion's docstring, correct a stale test label, and rename LoanPay's precision log fields from ValueChange to AssetsTotalDelta to match what they actually print --- include/xrpl/ledger/helpers/VaultHelpers.h | 6 ++++-- src/libxrpl/tx/transactors/lending/LoanPay.cpp | 4 ++-- src/test/app/Vault_test.cpp | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index e0101bd742..5681cc57e8 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -112,11 +112,13 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref * Resolves a Vault's LEVersion, the single point every accounting touch * point should call to determine which recognition model (accrual vs. * cash-basis) a Vault uses. Vaults created before featureLendingProtocolV1_1 - * activated never have sfLEVersion set, which resolves here to 0 (legacy). + * activated never have sfLEVersion set, which resolves here to + * VaultVersion::Legacy. * * @param vault The vault SLE. * - * @return The Vault's LEVersion, or 0 if the field is absent. + * @return The Vault's LEVersion, or VaultVersion::Legacy if the field is + * absent. */ [[nodiscard]] VaultVersion getVaultVersion(SLE::const_ref vault); diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 6566a7155d..5af6107eda 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -552,7 +552,7 @@ LoanPay::doApply() << "LoanPay: Vault assets expected change, but unchanged after rounding: " // << "Before: " << assetsTotalBefore // << ", After: " << assetsTotalAfter // - << ", ValueChange: " << assetsTotalDelta; + << ", AssetsTotalDelta: " << assetsTotalDelta; return tecPRECISION_LOSS; // LCOV_EXCL_STOP } @@ -566,7 +566,7 @@ LoanPay::doApply() JLOG(j_.fatal()) << "LoanPay: Vault assets changed unexpectedly after rounding: " // << "Before: " << assetsTotalBefore // << ", After: " << assetsTotalAfter // - << ", ValueChange: " << assetsTotalDelta; + << ", AssetsTotalDelta: " << assetsTotalDelta; return tecINTERNAL; // LCOV_EXCL_STOP } diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 0fbfd1a52f..d4bab0fb80 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -7672,7 +7672,9 @@ class Vault_test : public beast::unit_test::Suite } { - testcase("VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == 2"); + testcase( + "VaultCreate LEVersion: featureLendingProtocolV1_1 enabled, LEVersion == " + "VaultVersion::CashBasis"); Env env{*this}; env.fund(XRP(1'000'000), owner); env.close(); From b9b3aedc88bd00b7f24049517d36de3b302560d6 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:44:28 +0200 Subject: [PATCH 06/12] fix: Skip AssetsMaximum interest check under cash-basis accounting Cash-basis origination never recognizes interest into Vault.AssetsTotal, so checking interestDue against remaining AssetsMaximum headroom rejects loans for no reason. Split the check into Accrual/CashBasis namespaces, mirroring the existing loanOriginationDeltas/loanPaymentDeltas dispatch. --- include/xrpl/ledger/helpers/LendingHelpers.h | 15 +++ src/libxrpl/ledger/helpers/LendingHelpers.cpp | 25 +++++ .../tx/transactors/lending/LoanSet.cpp | 4 +- src/test/app/LendingHelpers_test.cpp | 106 +++++++++++++++++- src/test/app/Loan_test.cpp | 32 +++--- 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 6e1299687b..5503fe5b97 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -302,6 +302,14 @@ namespace Accrual { AccountingDeltas loanOriginationDeltas(Number const& principalRequested, Number const& interestDue); +// LoanSet origination: would recognizing this loan's interest push +// Vault.AssetsTotal past Vault.AssetsMaximum? +bool +loanOriginationExceedsVaultMaximum( + Number const& vaultMaximum, + Number const& vaultTotal, + Number const& interestDue); + // LoanManage impair/unimpair/default: the vault's exposure to this loan Number loanVaultExposure(SLE::const_ref loanSle); @@ -338,6 +346,13 @@ loanOriginationDeltas( Number const& principalRequested, Number const& interestDue); +bool +loanOriginationExceedsVaultMaximum( + Rules const& rules, + SLE::const_ref vaultSle, + Number const& vaultTotal, + Number const& interestDue); + Number loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle); diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 988c9cbcc1..4259b8eee9 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -139,6 +139,15 @@ loanOriginationDeltas(Number const& principalRequested, Number const& interestDu return {.assetsTotalDelta = interestDue, .debtTotalDelta = principalRequested + interestDue}; } +bool +loanOriginationExceedsVaultMaximum( + Number const& vaultMaximum, + Number const& vaultTotal, + Number const& interestDue) +{ + return vaultMaximum != 0 && interestDue > vaultMaximum - vaultTotal; +} + Number loanVaultExposure(SLE::const_ref loanSle) { @@ -204,6 +213,22 @@ loanOriginationDeltas( : Accrual::loanOriginationDeltas(principalRequested, interestDue); } +bool +loanOriginationExceedsVaultMaximum( + Rules const& rules, + SLE::const_ref vaultSle, + Number const& vaultTotal, + Number const& interestDue) +{ + // Cash-basis origination doesn't recognize interest into AssetsTotal, so + // interest due can never push the vault past AssetsMaximum at origination. + if (cashBasisEnabled(rules, vaultSle)) + return false; + + auto const vaultMaximum = vaultSle->at(sfAssetsMaximum); + return Accrual::loanOriginationExceedsVaultMaximum(vaultMaximum, vaultTotal, interestDue); +} + Number loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle) { diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 52d3361db7..c5b9c420a9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -444,7 +444,9 @@ LoanSet::doApply() vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, "xrpl::LoanSet::doApply", "Vault is below maximum limit"); - if (vaultMaximum != 0 && state.interestDue > vaultMaximum - vaultTotalProxy) + + if (loanOriginationExceedsVaultMaximum( + ctx_.view().rules(), vaultSle, vaultTotalProxy, state.interestDue)) { JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault"; return tecLIMIT_EXCEEDED; diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index b27c976646..82f455ce6b 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -1530,6 +1530,52 @@ class LendingHelpers_test : public beast::unit_test::Suite to_string(deltas.debtTotalDelta)); } + void + testAccrualLoanOriginationExceedsVaultMaximum() + { + using namespace xrpl::Accrual; + + struct TestCase + { + std::string name; + Number vaultMaximum; + Number vaultTotal; + Number interestDue; + bool expected; + }; + + auto const testCases = std::vector{ + {.name = "No maximum configured", + .vaultMaximum = Number{0}, + .vaultTotal = Number{900}, + .interestDue = Number{1'000}, + .expected = false}, + {.name = "Interest fits under headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{50}, + .expected = false}, + {.name = "Interest exactly fills headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{100}, + .expected = false}, + {.name = "Interest exceeds headroom", + .vaultMaximum = Number{1'000}, + .vaultTotal = Number{900}, + .interestDue = Number{101}, + .expected = true}, + }; + + for (auto const& tc : testCases) + { + testcase("Accrual::loanOriginationExceedsVaultMaximum: " + tc.name); + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + tc.vaultMaximum, tc.vaultTotal, tc.interestDue) == tc.expected); + } + } + // Constructs a minimal ltLOAN SLE with just the fields needed by // loanVaultExposure. Mirrors the bare-SLE pattern used by // testCanApplyToBrokerCover for ltLOAN_BROKER. @@ -1549,11 +1595,18 @@ class LendingHelpers_test : public beast::unit_test::Suite // Constructs a minimal ltVAULT SLE with just LEVersion set (or left // absent), for exercising the dispatchers' per-Vault gating. static std::shared_ptr - makeVaultSle(std::optional leVersion = std::nullopt) + makeVaultSle( + std::optional leVersion = std::nullopt, + std::optional assetsMaximum = std::nullopt, + std::optional assetsTotal = std::nullopt) { auto sle = std::make_shared(ltVAULT, uint256{2u}); if (leVersion) sle->at(sfLEVersion) = std::to_underlying(*leVersion); + if (assetsMaximum) + sle->at(sfAssetsMaximum) = *assetsMaximum; + if (assetsTotal) + sle->at(sfAssetsTotal) = *assetsTotal; return sle; } @@ -1653,6 +1706,55 @@ class LendingHelpers_test : public beast::unit_test::Suite } } + void + testLoanOriginationExceedsVaultMaximumDispatcher() + { + using namespace jtx; + + Number const vaultMaximum{1'000}; + Number const vaultTotal{900}; + // Exceeds Accrual's headroom (100), but must never trip CashBasis. + Number const interestDue{101}; + + auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal); + auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal); + + { + testcase( + "loanOriginationExceedsVaultMaximum dispatcher: amendment disabled picks " + "Accrual"); + Env env{*this}; + env.disableFeature(featureLendingProtocolV1_1); + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + env.current()->rules(), cashBasisVault, vaultTotal, interestDue) == + xrpl::Accrual::loanOriginationExceedsVaultMaximum( + vaultMaximum, vaultTotal, interestDue)); + } + + { + testcase( + "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault " + "picks Accrual"); + Env const env{*this}; + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + env.current()->rules(), legacyVault, vaultTotal, interestDue) == + xrpl::Accrual::loanOriginationExceedsVaultMaximum( + vaultMaximum, vaultTotal, interestDue)); + } + + { + testcase( + "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, LEVersion == " + "VaultVersion::CashBasis picks CashBasis"); + Env const env{*this}; + BEAST_EXPECT( + loanOriginationExceedsVaultMaximum( + env.current()->rules(), cashBasisVault, vaultTotal, interestDue) == false); + } + } + void testLoanVaultExposureDispatcher() { @@ -1845,10 +1947,12 @@ public: testAccrualLoanOriginationDeltas(); testCashBasisLoanOriginationDeltas(); + testAccrualLoanOriginationExceedsVaultMaximum(); testAccrualLoanVaultExposure(); testCashBasisLoanVaultExposure(); testLoanPaymentDeltas(); testLoanOriginationDeltasDispatcher(); + testLoanOriginationExceedsVaultMaximumDispatcher(); testLoanVaultExposureDispatcher(); testLoanPaymentDeltasDispatcher(); } diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 3d124b9dc4..ad21a82462 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -8683,9 +8683,9 @@ protected: 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. + // AssetsMaximum guard checks interestDue headroom only under + // whole-life accounting (cash-basis never adds interest to + // AssetsTotal). DebtMaximum guard is unconditional either way. auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { Env env(*this, features); @@ -8745,18 +8745,24 @@ protected: }; Number const oneDrop = xrpAsset(1).value(); + { + testcase("whole-life: LoanSet AssetsMaximum guard checks interestDue headroom"); + // Guard rejects when there's not quite enough headroom for the + // interest. + runVaultGuard(all_, interestDueCash - oneDrop, tecLIMIT_EXCEEDED); + // Guard accepts at the exact boundary. + runVaultGuard(all_, interestDueCash, tesSUCCESS); + } + + { + testcase("cash-basis: LoanSet AssetsMaximum guard ignores interestDue headroom"); + // Even far less headroom than interestDue still succeeds, since + // cash-basis origination never adds interest to AssetsTotal. + runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); + } + 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)")); From 20358587ce1edcb4ed7502f5e1b83e9dc68098bc Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:55:58 +0200 Subject: [PATCH 07/12] fix: Use cash-basis-aware debt delta for LoanSet DebtMaximum guard Remove unused vaultMaximum local left over from the AssetsMaximum guard refactor, and stop the DebtMaximum guard from unconditionally adding interestDue to the projected DebtTotal. Reuse the existing loanOriginationDeltas dispatcher, which already excludes interestDue under cash-basis accounting. --- src/libxrpl/tx/transactors/lending/LoanSet.cpp | 10 ++++------ src/test/app/Loan_test.cpp | 15 +++++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index c5b9c420a9..a4575e40b0 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -439,9 +439,8 @@ LoanSet::doApply() principalRequested, properties.loanState.managementFeeDue); - auto const vaultMaximum = *vaultSle->at(sfAssetsMaximum); XRPL_ASSERT_PARTS( - vaultMaximum == 0 || vaultMaximum > *vaultTotalProxy, + *vaultSle->at(sfAssetsMaximum) == 0 || *vaultSle->at(sfAssetsMaximum) > *vaultTotalProxy, "xrpl::LoanSet::doApply", "Vault is below maximum limit"); @@ -492,8 +491,9 @@ LoanSet::doApply() auto const loanAssetsToBorrower = principalRequested - originationFee; - auto const newDebtDelta = principalRequested + state.interestDue; - auto const newDebtTotal = brokerSle->at(sfDebtTotal) + newDebtDelta; + auto const [assetsTotalDelta, debtTotalDelta] = + loanOriginationDeltas(ctx_.view().rules(), vaultSle, principalRequested, state.interestDue); + auto const newDebtTotal = brokerSle->at(sfDebtTotal) + debtTotalDelta; if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); debtMaximum != 0 && debtMaximum < newDebtTotal) { @@ -635,8 +635,6 @@ LoanSet::doApply() view.insert(loan); // Update the balances in the vault - auto const [assetsTotalDelta, debtTotalDelta] = - loanOriginationDeltas(ctx_.view().rules(), vaultSle, principalRequested, state.interestDue); vaultAvailableProxy -= principalRequested; vaultTotalProxy += assetsTotalDelta; XRPL_ASSERT_PARTS( diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index ad21a82462..8a6f1669df 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -8684,8 +8684,7 @@ protected: } // AssetsMaximum guard checks interestDue headroom only under - // whole-life accounting (cash-basis never adds interest to - // AssetsTotal). DebtMaximum guard is unconditional either way. + // whole-life accounting; DebtMaximum guard also varies by model. auto runVaultGuard = [&](FeatureBitset features, Number const& slack, TER expected) { Env env(*this, features); @@ -8761,12 +8760,16 @@ protected: runVaultGuard(all_ | featureLendingProtocolV1_1, oneDrop, tesSUCCESS); } - for (auto const features : {all_ | featureLendingProtocolV1_1, all_}) + // DebtMaximum guard: cash-basis projects principal-only DebtTotal; + // whole-life projects principal + interestDue. + for (auto const cashBasis : {true, false}) { testcase( - std::string("cash-basis: LoanSet DebtMaximum guard unaffected by amendment (") + - (features[featureLendingProtocolV1_1] ? "enabled)" : "disabled)")); - Number const newDebtTotal = principalOutstandingCash + interestDueCash; + std::string("LoanSet DebtMaximum guard (") + + (cashBasis ? "cash-basis)" : "whole-life)")); + auto const features = cashBasis ? all_ | featureLendingProtocolV1_1 : all_; + Number const newDebtTotal = + principalOutstandingCash + (cashBasis ? Number{} : interestDueCash); runBrokerGuard(features, newDebtTotal - oneDrop, tecLIMIT_EXCEEDED); runBrokerGuard(features, newDebtTotal, tesSUCCESS); } From 9a2b4b7dc42f8d7f1b4f3ae666e2b9d65023d96e Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:49:22 +0200 Subject: [PATCH 08/12] refactor: Drop unused rules argument from cashBasisEnabled callers cashBasisEnabled now only checks the Vault's LEVersion, so the Rules parameter threaded through loanOriginationDeltas, loanOriginationExceedsVaultMaximum, loanVaultExposure, and loanPaymentDeltas is dead weight. Remove it from the declarations, definitions, and call sites. --- include/xrpl/ledger/helpers/LendingHelpers.h | 6 ++-- src/libxrpl/ledger/helpers/LendingHelpers.cpp | 35 +++++++++++------- .../tx/transactors/lending/LoanManage.cpp | 6 ++-- .../tx/transactors/lending/LoanPay.cpp | 3 +- .../tx/transactors/lending/LoanSet.cpp | 5 ++- src/test/app/LendingHelpers_test.cpp | 36 ++++++++----------- 6 files changed, 45 insertions(+), 46 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 5503fe5b97..fef18e3e09 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -341,23 +341,21 @@ loanPaymentDeltas(LoanPaymentParts const& parts); // transactors call. AccountingDeltas loanOriginationDeltas( - Rules const& rules, SLE::const_ref vaultSle, Number const& principalRequested, Number const& interestDue); bool loanOriginationExceedsVaultMaximum( - Rules const& rules, SLE::const_ref vaultSle, Number const& vaultTotal, Number const& interestDue); Number -loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle); +loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle); AccountingDeltas -loanPaymentDeltas(Rules const& rules, SLE::const_ref vaultSle, LoanPaymentParts const& parts); +loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts); namespace detail { // These classes and functions should only be accessed by LendingHelper diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 4259b8eee9..dac2c67181 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -148,6 +148,13 @@ loanOriginationExceedsVaultMaximum( return vaultMaximum != 0 && interestDue > vaultMaximum - vaultTotal; } +/* +XLS-66 section 3.2.3.2, defines the default amount as + +DefaultAmount = (Loan.PrincipalOutstanding + Loan.InterestOutstanding) + +Which is equivalent to (Loan.TotalValueOutstanding - Loan.ManagementFeeOutstanding) +*/ Number loanVaultExposure(SLE::const_ref loanSle) { @@ -172,6 +179,11 @@ loanOriginationDeltas(Number const& principalRequested) return {.assetsTotalDelta = kNumZero, .debtTotalDelta = principalRequested}; } +/* + * Under CashBasis accounting, Loan default amount is: + * + * DefaultAmount = Loan.PrincipalOutstanding + */ Number loanVaultExposure(SLE::const_ref loanSle) { @@ -193,36 +205,33 @@ namespace { // VaultVersion::CashBasis). Vaults created before activation keep accrual-basis // accounting forever, even after the amendment later turns on. bool -cashBasisEnabled(Rules const& rules, SLE::const_ref vaultSle) +cashBasisEnabled(SLE::const_ref vaultSle) { - return rules.enabled(featureLendingProtocolV1_1) && - getVaultVersion(vaultSle) == VaultVersion::CashBasis; + return getVaultVersion(vaultSle) == VaultVersion::CashBasis; } } // namespace AccountingDeltas loanOriginationDeltas( - Rules const& rules, SLE::const_ref vaultSle, Number const& principalRequested, Number const& interestDue) { - return cashBasisEnabled(rules, vaultSle) + return cashBasisEnabled(vaultSle) ? CashBasis::loanOriginationDeltas(principalRequested) : Accrual::loanOriginationDeltas(principalRequested, interestDue); } bool loanOriginationExceedsVaultMaximum( - Rules const& rules, SLE::const_ref vaultSle, Number const& vaultTotal, Number const& interestDue) { // Cash-basis origination doesn't recognize interest into AssetsTotal, so // interest due can never push the vault past AssetsMaximum at origination. - if (cashBasisEnabled(rules, vaultSle)) + if (cashBasisEnabled(vaultSle)) return false; auto const vaultMaximum = vaultSle->at(sfAssetsMaximum); @@ -230,17 +239,17 @@ loanOriginationExceedsVaultMaximum( } Number -loanVaultExposure(Rules const& rules, SLE::const_ref vaultSle, SLE::const_ref loanSle) +loanVaultExposure(SLE::const_ref vaultSle, SLE::const_ref loanSle) { - return cashBasisEnabled(rules, vaultSle) ? CashBasis::loanVaultExposure(loanSle) - : Accrual::loanVaultExposure(loanSle); + return cashBasisEnabled(vaultSle) ? CashBasis::loanVaultExposure(loanSle) + : Accrual::loanVaultExposure(loanSle); } AccountingDeltas -loanPaymentDeltas(Rules const& rules, SLE::const_ref vaultSle, LoanPaymentParts const& parts) +loanPaymentDeltas(SLE::const_ref vaultSle, LoanPaymentParts const& parts) { - return cashBasisEnabled(rules, vaultSle) ? CashBasis::loanPaymentDeltas(parts) - : Accrual::loanPaymentDeltas(parts); + return cashBasisEnabled(vaultSle) ? CashBasis::loanPaymentDeltas(parts) + : Accrual::loanPaymentDeltas(parts); } namespace detail { diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index 5c71843129..a312dba3b3 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -141,7 +141,7 @@ LoanManage::defaultLoan( std::int32_t const loanScale = loanSle->at(sfLoanScale); auto brokerDebtTotalProxy = brokerSle->at(sfDebtTotal); - Number const totalDefaultAmount = loanVaultExposure(view.rules(), vaultSle, loanSle); + Number const totalDefaultAmount = loanVaultExposure(vaultSle, loanSle); // Apply the First-Loss Capital to the Default Amount TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; @@ -287,7 +287,7 @@ LoanManage::impairLoan( Asset const& vaultAsset, beast::Journal j) { - Number const lossUnrealized = loanVaultExposure(view.rules(), vaultSle, loanSle); + Number const lossUnrealized = loanVaultExposure(vaultSle, 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 @@ -336,7 +336,7 @@ LoanManage::unimpairLoan( // Update the Vault object(clear "paper loss") auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - Number const lossReversed = loanVaultExposure(view.rules(), vaultSle, loanSle); + Number const lossReversed = loanVaultExposure(vaultSle, loanSle); if (vaultLossUnrealizedProxy < lossReversed) { // LCOV_EXCL_START diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 5af6107eda..0053ed496e 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -420,8 +420,7 @@ LoanPay::doApply() // LCOV_EXCL_STOP } - auto const [assetsTotalDelta, debtTotalDelta] = - loanPaymentDeltas(view.rules(), vaultSle, *paymentParts); + auto const [assetsTotalDelta, debtTotalDelta] = loanPaymentDeltas(vaultSle, *paymentParts); JLOG(j_.debug()) << "Loan Pay: principal paid: " << paymentParts->principalPaid << ", interest paid: " << paymentParts->interestPaid diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index a4575e40b0..bafadd7c1d 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -444,8 +444,7 @@ LoanSet::doApply() "xrpl::LoanSet::doApply", "Vault is below maximum limit"); - if (loanOriginationExceedsVaultMaximum( - ctx_.view().rules(), vaultSle, vaultTotalProxy, state.interestDue)) + if (loanOriginationExceedsVaultMaximum(vaultSle, vaultTotalProxy, state.interestDue)) { JLOG(j_.warn()) << "Loan would exceed the maximum assets of the vault"; return tecLIMIT_EXCEEDED; @@ -492,7 +491,7 @@ LoanSet::doApply() auto const loanAssetsToBorrower = principalRequested - originationFee; auto const [assetsTotalDelta, debtTotalDelta] = - loanOriginationDeltas(ctx_.view().rules(), vaultSle, principalRequested, state.interestDue); + loanOriginationDeltas(vaultSle, principalRequested, state.interestDue); auto const newDebtTotal = brokerSle->at(sfDebtTotal) + debtTotalDelta; if (auto const debtMaximum = brokerSle->at(sfDebtMaximum); debtMaximum != 0 && debtMaximum < newDebtTotal) diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index 82f455ce6b..94f5813bd8 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -1672,8 +1672,8 @@ class LendingHelpers_test : public beast::unit_test::Suite testcase("loanOriginationDeltas dispatcher: amendment disabled picks Accrual"); Env env{*this}; env.disableFeature(featureLendingProtocolV1_1); - auto const deltas = loanOriginationDeltas( - env.current()->rules(), cashBasisVault, principalRequested, interestDue); + auto const deltas = + loanOriginationDeltas(cashBasisVault, principalRequested, interestDue); auto const expected = xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); @@ -1685,8 +1685,7 @@ class LendingHelpers_test : public beast::unit_test::Suite "loanOriginationDeltas dispatcher: amendment enabled, legacy vault picks " "Accrual"); Env const env{*this}; - auto const deltas = loanOriginationDeltas( - env.current()->rules(), legacyVault, principalRequested, interestDue); + auto const deltas = loanOriginationDeltas(legacyVault, principalRequested, interestDue); auto const expected = xrpl::Accrual::loanOriginationDeltas(principalRequested, interestDue); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); @@ -1698,8 +1697,8 @@ class LendingHelpers_test : public beast::unit_test::Suite "loanOriginationDeltas dispatcher: amendment enabled, LEVersion == " "VaultVersion::CashBasis picks CashBasis"); Env const env{*this}; - auto const deltas = loanOriginationDeltas( - env.current()->rules(), cashBasisVault, principalRequested, interestDue); + auto const deltas = + loanOriginationDeltas(cashBasisVault, principalRequested, interestDue); auto const expected = xrpl::CashBasis::loanOriginationDeltas(principalRequested); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); @@ -1726,8 +1725,7 @@ class LendingHelpers_test : public beast::unit_test::Suite Env env{*this}; env.disableFeature(featureLendingProtocolV1_1); BEAST_EXPECT( - loanOriginationExceedsVaultMaximum( - env.current()->rules(), cashBasisVault, vaultTotal, interestDue) == + loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) == xrpl::Accrual::loanOriginationExceedsVaultMaximum( vaultMaximum, vaultTotal, interestDue)); } @@ -1738,8 +1736,7 @@ class LendingHelpers_test : public beast::unit_test::Suite "picks Accrual"); Env const env{*this}; BEAST_EXPECT( - loanOriginationExceedsVaultMaximum( - env.current()->rules(), legacyVault, vaultTotal, interestDue) == + loanOriginationExceedsVaultMaximum(legacyVault, vaultTotal, interestDue) == xrpl::Accrual::loanOriginationExceedsVaultMaximum( vaultMaximum, vaultTotal, interestDue)); } @@ -1750,8 +1747,8 @@ class LendingHelpers_test : public beast::unit_test::Suite "VaultVersion::CashBasis picks CashBasis"); Env const env{*this}; BEAST_EXPECT( - loanOriginationExceedsVaultMaximum( - env.current()->rules(), cashBasisVault, vaultTotal, interestDue) == false); + loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) == + false); } } @@ -1769,8 +1766,7 @@ class LendingHelpers_test : public beast::unit_test::Suite env.disableFeature(featureLendingProtocolV1_1); auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); BEAST_EXPECT( - loanVaultExposure(env.current()->rules(), cashBasisVault, sle) == - xrpl::Accrual::loanVaultExposure(sle)); + loanVaultExposure(cashBasisVault, sle) == xrpl::Accrual::loanVaultExposure(sle)); } { @@ -1778,8 +1774,7 @@ class LendingHelpers_test : public beast::unit_test::Suite Env const env{*this}; auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); BEAST_EXPECT( - loanVaultExposure(env.current()->rules(), legacyVault, sle) == - xrpl::Accrual::loanVaultExposure(sle)); + loanVaultExposure(legacyVault, sle) == xrpl::Accrual::loanVaultExposure(sle)); } { @@ -1790,8 +1785,7 @@ class LendingHelpers_test : public beast::unit_test::Suite Env const env{*this}; auto sle = makeLoanSle(Number{1'000}, Number{800}, Number{50}); BEAST_EXPECT( - loanVaultExposure(env.current()->rules(), cashBasisVault, sle) == - xrpl::CashBasis::loanVaultExposure(sle)); + loanVaultExposure(cashBasisVault, sle) == xrpl::CashBasis::loanVaultExposure(sle)); } } @@ -1813,7 +1807,7 @@ class LendingHelpers_test : public beast::unit_test::Suite testcase("loanPaymentDeltas dispatcher: amendment disabled picks Accrual"); Env env{*this}; env.disableFeature(featureLendingProtocolV1_1); - auto const deltas = loanPaymentDeltas(env.current()->rules(), cashBasisVault, parts); + auto const deltas = loanPaymentDeltas(cashBasisVault, parts); auto const expected = xrpl::Accrual::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); @@ -1822,7 +1816,7 @@ class LendingHelpers_test : public beast::unit_test::Suite { testcase("loanPaymentDeltas dispatcher: amendment enabled, legacy vault picks Accrual"); Env const env{*this}; - auto const deltas = loanPaymentDeltas(env.current()->rules(), legacyVault, parts); + auto const deltas = loanPaymentDeltas(legacyVault, parts); auto const expected = xrpl::Accrual::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); @@ -1834,7 +1828,7 @@ class LendingHelpers_test : public beast::unit_test::Suite "VaultVersion::CashBasis " "picks CashBasis"); Env const env{*this}; - auto const deltas = loanPaymentDeltas(env.current()->rules(), cashBasisVault, parts); + auto const deltas = loanPaymentDeltas(cashBasisVault, parts); auto const expected = xrpl::CashBasis::loanPaymentDeltas(parts); BEAST_EXPECT(deltas.assetsTotalDelta == expected.assetsTotalDelta); BEAST_EXPECT(deltas.debtTotalDelta == expected.debtTotalDelta); From b878818e809455cb82dc055a473e066d309fa9bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:55:01 +0000 Subject: [PATCH 09/12] ci: [DEPENDABOT] bump actions/checkout from 7.0.0 to 7.0.1 (#7871) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check-pr-description.yml | 2 +- .github/workflows/check-tools.yml | 4 ++-- .github/workflows/on-pr.yml | 2 +- .github/workflows/publish-docs.yml | 2 +- .github/workflows/reusable-build-test-config.yml | 2 +- .github/workflows/reusable-check-levelization.yml | 2 +- .github/workflows/reusable-check-rename.yml | 2 +- .github/workflows/reusable-clang-tidy.yml | 2 +- .github/workflows/reusable-package.yml | 4 ++-- .github/workflows/reusable-strategy-matrix.yml | 2 +- .github/workflows/reusable-upload-recipe.yml | 2 +- .github/workflows/upload-conan-deps.yml | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/check-pr-description.yml b/.github/workflows/check-pr-description.yml index 744449f216..f8e7b6cdc4 100644 --- a/.github/workflows/check-pr-description.yml +++ b/.github/workflows/check-pr-description.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Write PR body to file env: diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml index 6daaf98114..af20c5f17e 100644 --- a/.github/workflows/check-tools.yml +++ b/.github/workflows/check-tools.yml @@ -47,7 +47,7 @@ jobs: tag: ${{ steps.tag.outputs.tag }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Read nix image tag id: tag @@ -76,7 +76,7 @@ jobs: container: ${{ !matrix.nix_develop && format('ghcr.io/xrplf/xrpld/nix-ubuntu:{0}', needs.linux-image-tag.outputs.tag) || null }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 13c807ffca..1cd97305da 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Determine changed files # This step checks whether any files have changed that should # cause the next jobs to run. We do it this way rather than diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 49f5c021a3..c1e67e2010 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -44,7 +44,7 @@ jobs: container: ghcr.io/xrplf/xrpld/nix-ubuntu:sha-40cdf49 steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 diff --git a/.github/workflows/reusable-build-test-config.yml b/.github/workflows/reusable-build-test-config.yml index 6372bb6328..74425febe8 100644 --- a/.github/workflows/reusable-build-test-config.yml +++ b/.github/workflows/reusable-build-test-config.yml @@ -110,7 +110,7 @@ jobs: uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4 - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 diff --git a/.github/workflows/reusable-check-levelization.yml b/.github/workflows/reusable-check-levelization.yml index 88c95ac3ba..7f547f2ab6 100644 --- a/.github/workflows/reusable-check-levelization.yml +++ b/.github/workflows/reusable-check-levelization.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check levelization run: python .github/scripts/levelization/generate.py - name: Check for differences diff --git a/.github/workflows/reusable-check-rename.yml b/.github/workflows/reusable-check-rename.yml index 9a91e98ee3..874c8adcde 100644 --- a/.github/workflows/reusable-check-rename.yml +++ b/.github/workflows/reusable-check-rename.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check definitions run: .github/scripts/rename/definitions.sh . - name: Check copyright notices diff --git a/.github/workflows/reusable-clang-tidy.yml b/.github/workflows/reusable-clang-tidy.yml index 90f24bc464..3c19b58a12 100644 --- a/.github/workflows/reusable-clang-tidy.yml +++ b/.github/workflows/reusable-clang-tidy.yml @@ -40,7 +40,7 @@ jobs: issues: write steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 diff --git a/.github/workflows/reusable-package.yml b/.github/workflows/reusable-package.yml index 55bc20dc5c..e1c11ac677 100644 --- a/.github/workflows/reusable-package.yml +++ b/.github/workflows/reusable-package.yml @@ -27,7 +27,7 @@ jobs: matrix: ${{ steps.generate.outputs.matrix }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 @@ -54,7 +54,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download pre-built binary uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/reusable-strategy-matrix.yml b/.github/workflows/reusable-strategy-matrix.yml index de8d9cfc8e..12f11b0fbe 100644 --- a/.github/workflows/reusable-strategy-matrix.yml +++ b/.github/workflows/reusable-strategy-matrix.yml @@ -23,7 +23,7 @@ jobs: matrix: ${{ steps.generate.outputs.matrix }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 diff --git a/.github/workflows/reusable-upload-recipe.yml b/.github/workflows/reusable-upload-recipe.yml index 0f00ce7ca0..bce4da2df6 100644 --- a/.github/workflows/reusable-upload-recipe.yml +++ b/.github/workflows/reusable-upload-recipe.yml @@ -47,7 +47,7 @@ jobs: CONAN_PASSWORD_XRPLF: ${{ secrets.remote_password }} steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate build version number id: version diff --git a/.github/workflows/upload-conan-deps.yml b/.github/workflows/upload-conan-deps.yml index 8a02c4c2db..80a75a1fbf 100644 --- a/.github/workflows/upload-conan-deps.yml +++ b/.github/workflows/upload-conan-deps.yml @@ -65,7 +65,7 @@ jobs: uses: XRPLF/actions/cleanup-workspace@c7d9ce5ebb03c752a354889ecd870cadfc2b1cd4 - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Prepare runner uses: XRPLF/actions/prepare-runner@e4b6449d55a61c002d7c3fdfa6c20f721ede0606 From 20801d98acd36f1aa4bc283ccc00d46db6d20f14 Mon Sep 17 00:00:00 2001 From: Andrzej Budzanowski Date: Mon, 27 Jul 2026 13:58:15 +0200 Subject: [PATCH 10/12] test: Improve the server status test to not race and randomly fail (#7304) Co-authored-by: Alex Kremer --- src/test/jtx/AbstractClient.h | 11 +++ src/test/jtx/Env.h | 70 ++++++++++++++++-- src/test/jtx/WSClient_test.cpp | 44 ++++++++++- src/test/jtx/impl/JSONRPCClient.cpp | 92 +++++++++++++++++++++-- src/test/jtx/impl/WSClient.cpp | 101 ++++++++++++++++++-------- src/test/server/ServerStatus_test.cpp | 48 ++++++++---- 6 files changed, 307 insertions(+), 59 deletions(-) diff --git a/src/test/jtx/AbstractClient.h b/src/test/jtx/AbstractClient.h index f9d8de0768..58f57f67a5 100644 --- a/src/test/jtx/AbstractClient.h +++ b/src/test/jtx/AbstractClient.h @@ -40,6 +40,17 @@ public: */ [[nodiscard]] virtual unsigned version() const = 0; + + /** + * Close the client's connection to the server. + * + * Releases the connection the client holds against the server's per-port + * connection limit. After this call the client must not be used to + * invoke() again. Tests use this to deterministically free the slot + * rather than waiting for the server's idle timeout to drop it. + */ + virtual void + disconnect() = 0; }; } // namespace xrpl::test diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h index 0df62c7e9b..a175fd5006 100644 --- a/src/test/jtx/Env.h +++ b/src/test/jtx/Env.h @@ -482,11 +482,52 @@ public: app().getNumberOfThreads() == 1, "syncClose() is only useful on an application with a single thread"); auto const result = close(); - auto serverBarrier = std::make_shared>(); - auto future = serverBarrier->get_future(); - boost::asio::post(app().getIOContext(), [serverBarrier]() { serverBarrier->set_value(); }); - auto const status = future.wait_for(timeout); - return result && status == std::future_status::ready; + return result && drainServerIo(timeout); + } + + /** + * Disconnect the Env's built-in client and wait for the server to + * register the dropped connection. + * + * Env holds one persistent client connection to the server's RPC port for + * its whole lifetime (see client()), and that connection counts against + * the port's connection limit. Tests that need a known starting occupancy + * can call this to deterministically release that slot instead of waiting + * out the server's localhost idle timeout. + * + * The server decrements its per-port connection count in the peer's + * destructor, which runs when the io_context processes the end-of-stream + * on the closed socket. After closing the client this drains the server's + * io_context twice: the first barrier guarantees the reactor has reaped + * the closed socket and queued the peer's teardown, and the second + * guarantees that teardown (and therefore the count decrement) has run. + * + * This is only sound when the server uses a single io_context thread, so + * that draining establishes ordering against the teardown - configure the + * Env with singleThreadIo() (as syncClose() also requires). Like + * syncClose(), it relies on loopback teardown latency being negligible. + * + * @param timeout Maximum time to wait for each barrier task to execute + * @return true if both barriers executed within timeout, false otherwise + */ + [[nodiscard]] bool + disconnectClient(std::chrono::steady_clock::duration timeout = std::chrono::seconds{1}) + { + XRPL_ASSERT( + app().getNumberOfThreads() == 1, + "disconnectClient() is only useful on an application with a single " + "thread"); + + bundle_.client->disconnect(); + + // Drain the server's single io thread twice: the first barrier flushes + // the reactor's reap of the closed socket (queuing the peer teardown), + // the second flushes that teardown - and therefore the connection-count + // decrement. Both run unconditionally so a timed-out first drain does + // not short-circuit the second. + bool const reaped = drainServerIo(timeout); + bool const toreDown = drainServerIo(timeout); + return reaped && toreDown; } /** @@ -846,6 +887,25 @@ public: } private: + /** + * Drain the (single) server io_context thread once. + * + * Posts a barrier task to the server's io_context and blocks until it + * runs, so every task queued before it has been processed. Only meaningful + * with a single io thread (see syncClose()/disconnectClient()). + * + * @param timeout Maximum time to wait for the barrier task to execute + * @return true if the barrier ran within timeout, false otherwise + */ + [[nodiscard]] bool + drainServerIo(std::chrono::steady_clock::duration timeout) + { + auto barrier = std::make_shared>(); + auto future = barrier->get_future(); + boost::asio::post(app().getIOContext(), [barrier]() { barrier->set_value(); }); + return future.wait_for(timeout) == std::future_status::ready; + } + void fund(bool setDefaultRipple, STAmount const& amount, Account const& account); diff --git a/src/test/jtx/WSClient_test.cpp b/src/test/jtx/WSClient_test.cpp index d77e0f948b..801ca50504 100644 --- a/src/test/jtx/WSClient_test.cpp +++ b/src/test/jtx/WSClient_test.cpp @@ -13,8 +13,9 @@ class WSClient_test : public beast::unit_test::Suite { public: void - run() override + testSmoke() { + testcase("smoke"); using namespace jtx; Env env(*this); auto wsc = makeWSClient(env.app().config()); @@ -28,6 +29,47 @@ public: auto jv = wsc->getMsg(std::chrono::seconds(1)); pass(); } + + void + testGracefulDisconnect() + { + testcase("graceful disconnect"); + using namespace jtx; + using namespace std::chrono; + + Env env(*this); + auto wsc = makeWSClient(env.app().config()); + + // Put real traffic on the connection before closing it. + json::Value stream; + stream["streams"] = json::ValueType::Array; + stream["streams"].append("ledger"); + auto const sub = wsc->invoke("subscribe", stream); + BEAST_EXPECT(sub.isMember("result") || sub.isMember("status")); + + // disconnect() performs a graceful WebSocket closing handshake and + // blocks until the server acknowledges. On loopback that completes in + // well under its internal 1s timeout; only a broken async_close/ack + // coordination would fall through to the force-close path at ~1s. A + // generous bound keeps this from flaking under load while still + // catching that regression. + auto const start = steady_clock::now(); + wsc->disconnect(); + auto const elapsed = duration_cast(steady_clock::now() - start); + BEAST_EXPECT(elapsed < milliseconds{750}); + + // disconnect() must be idempotent: a second call (and the subsequent + // destructor) must not hang, double-close, or crash. + wsc->disconnect(); + pass(); + } + + void + run() override + { + testSmoke(); + testGracefulDisconnect(); + } }; BEAST_DEFINE_TESTSUITE(WSClient, jtx, xrpl); diff --git a/src/test/jtx/impl/JSONRPCClient.cpp b/src/test/jtx/impl/JSONRPCClient.cpp index 495fc5a657..06474c3616 100644 --- a/src/test/jtx/impl/JSONRPCClient.cpp +++ b/src/test/jtx/impl/JSONRPCClient.cpp @@ -14,18 +14,23 @@ #include #include +#include #include #include #include #include #include #include +#include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -84,6 +89,40 @@ class JSONRPCClient : public AbstractClient boost::beast::multi_buffer bout_; unsigned rpcVersion_; + bool disconnected_ = false; + + // Errors that mean the persistent keep-alive connection was dropped by the + // server (rather than a genuine protocol failure), so the request can be + // safely retried on a fresh connection. + static bool + droppedConnection(boost::system::error_code const& ec) + { + namespace error = boost::asio::error; + static auto const kDroppedConnectionErrors = std::to_array({ + boost::beast::http::error::end_of_stream, + error::eof, + error::connection_reset, + error::connection_aborted, + error::broken_pipe, + error::not_connected, + }); + + return std::ranges::any_of( + kDroppedConnectionErrors, + [&ec](boost::system::error_code const& e) { return ec == e; }); + } + + // Tear down and re-establish the socket to ep_, discarding any buffered + // bytes left over from the dropped connection. + void + reconnect() + { + boost::system::error_code ec; + stream_.close(ec); + bin_.clear(); + stream_.connect(ep_); + } + public: explicit JSONRPCClient(Config const& cfg, unsigned rpcVersion) : ep_(getEndpoint(cfg)), stream_(ios_), rpcVersion_(rpcVersion) @@ -91,12 +130,10 @@ public: stream_.connect(ep_); } - /* - Return value is an Object type with up to three keys: - status - error - result - */ + // Return value is an Object type with up to three keys: + // status + // error + // result json::Value invoke(std::string const& cmd, json::Value const& params) override { @@ -104,6 +141,13 @@ public: using namespace boost::asio; using namespace std::string_literals; + // Once disconnect() has released the slot, the client must not be + // reused (see AbstractClient::disconnect). Refuse rather than let the + // failed write/read below trip the reconnect path and silently + // re-consume a connection slot, which would defeat disconnectClient(). + if (disconnected_) + Throw("JSONRPCClient::invoke called after disconnect()"); + request req; req.method(boost::beast::http::verb::post); req.target("/"); @@ -131,10 +175,29 @@ public: req.body() = to_string(jr); } req.prepare_payload(); - write(stream_, req); + // The client keeps a single keep-alive connection for its whole + // lifetime, but the server drops idle localhost connections after a few + // seconds (BaseHTTPPeer::kTimeoutSecondsLocal). If a slow gap between + // requests let the server close the socket, the write/read here fails + // with end_of_stream; reconnect and retry the request exactly once. response res; - read(stream_, bin_, res); + auto writeAndRead = [&] { + write(stream_, req); + read(stream_, bin_, res); + }; + try + { + writeAndRead(); + } + catch (boost::system::system_error const& e) + { + if (!droppedConnection(e.code())) + throw; + reconnect(); + res = {}; + writeAndRead(); + } json::Reader jr; json::Value jv; @@ -151,6 +214,19 @@ public: { return rpcVersion_; } + + void + disconnect() override + { + if (disconnected_) + return; + + disconnected_ = true; + + boost::system::error_code ec; + stream_.shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec); + stream_.close(ec); + } }; std::unique_ptr diff --git a/src/test/jtx/impl/WSClient.cpp b/src/test/jtx/impl/WSClient.cpp index ca322415fb..a8702c12d9 100644 --- a/src/test/jtx/impl/WSClient.cpp +++ b/src/test/jtx/impl/WSClient.cpp @@ -2,6 +2,7 @@ #include +#include #include #include #include @@ -112,10 +113,11 @@ class WSClientImpl : public WSClient bool peerClosed_ = false; - // synchronize destructor - bool b0_ = false; - std::mutex m0_; - std::condition_variable cv0_; + // disconnect() waits on this until the read loop ends (for any reason: + // the server acknowledged our close, or a timeout force-closed the socket). + static constexpr auto kDisconnectTimeout = std::chrono::seconds{1}; + xrpl::Mutex readEnded_; + std::condition_variable readEndCv_; // synchronize message queue std::mutex m_; @@ -127,23 +129,26 @@ class WSClientImpl : public WSClient void cleanup() { - boost::asio::post(ios_, boost::asio::bind_executor(strand_, [this] { - if (!peerClosed_) - { - ws_.async_close( - {}, boost::asio::bind_executor(strand_, [&](error_code) { - try - { - stream_.cancel(); - } - // NOLINTNEXTLINE(bugprone-empty-catch) - catch (boost::system::system_error const&) - { - // ignored - } - })); - } - })); + boost::asio::post( + ios_, // + boost::asio::bind_executor(strand_, [this] { + if (!peerClosed_) + { + ws_.async_close( + {}, // + boost::asio::bind_executor(strand_, [&](error_code) { + try + { + stream_.cancel(); + } + // NOLINTNEXTLINE(bugprone-empty-catch) + catch (boost::system::system_error const&) + { + // ignored + } + })); + } + })); work_ = std::nullopt; thread_.join(); } @@ -289,6 +294,44 @@ public: return rpcVersion_; } + void + disconnect() override + { + // Perform a graceful WebSocket closing handshake and block until the + // read loop ends, so the server observes a clean close (not a RST) and + // has finished tearing the connection down by the time we return. + // If the server already closed, the wait below returns immediately. + boost::asio::post( + ios_, + boost::asio::bind_executor( + strand_, // + [this] { + if (!peerClosed_) + { + ws_.async_close( + boost::beast::websocket::close_code::normal, + boost::asio::bind_executor(strand_, [](error_code) {})); + } + })); + + auto lock = readEnded_.lock(); + readEndCv_.wait_for(lock, kDisconnectTimeout, [&lock] { return *lock; }); + + // On timeout (server gone or not replying) force the socket closed so + // the outstanding read ends and the worker thread can later be joined. + if (!*lock) + { + boost::asio::post( + ios_, + boost::asio::bind_executor( + strand_, // + [this] { + boost::system::error_code ec; + stream_.close(ec); + })); + } + } + private: void onReadMsg(error_code const& ec) @@ -297,33 +340,31 @@ private: { if (ec == boost::beast::websocket::error::closed) peerClosed_ = true; + + *readEnded_.lock() = true; + readEndCv_.notify_all(); + return; } json::Value jv; json::Reader jr; + jr.parse(bufferString(rb_.data()), jv); rb_.consume(rb_.size()); + auto m = std::make_shared(std::move(jv)); { std::scoped_lock const lock(m_); msgs_.push_front(m); cv_.notify_all(); } + ws_.async_read( rb_, boost::asio::bind_executor(strand_, [this](error_code const& ec, std::size_t) { onReadMsg(ec); })); } - - // Called when the read op terminates - void - onReadDone() - { - std::scoped_lock const lock(m0_); - b0_ = true; - cv0_.notify_all(); - } }; std::unique_ptr diff --git a/src/test/server/ServerStatus_test.cpp b/src/test/server/ServerStatus_test.cpp index 60ea622616..5adf6a08f5 100644 --- a/src/test/server/ServerStatus_test.cpp +++ b/src/test/server/ServerStatus_test.cpp @@ -558,10 +558,12 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En using namespace test::jtx; using namespace boost::asio; using namespace boost::beast::http; - Env env{*this, envconfig([&](std::unique_ptr cfg) { + // Run the server with a single io thread so disconnectClient() below + // can deterministically drain the server's io_context (see its docs). + Env env{*this, singleThreadIo(envconfig([&](std::unique_ptr cfg) { (*cfg)[Sections::kPortRpc].set(Keys::kLimit, std::to_string(limit)); return cfg; - })}; + }))}; auto const section = env.app().config().section(Sections::kPortRpc); // NOLINTBEGIN(bugprone-unchecked-optional-access) @@ -580,16 +582,27 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En BEAST_EXPECT(!ec); std::vector> clients; - int connectionCount{1}; // starts at 1 because the Env already has one - // for JSONRPCCLient - // for nonzero limits, go one past the limit, although failures happen - // at the limit, so this really leads to the last two clients failing. - // for zero limit, pick an arbitrary nonzero number of clients - all - // should connect fine. + // Env owns a persistent JSON-RPC HTTP client connection to port_rpc as + // part of startup, which counts against this port's connection limit. + // This test wants a known starting occupancy of zero, so for nonzero + // limits it deterministically drops that hidden client and waits for + // the server to register the disconnect before opening its own clients. + // + // Starting from zero is important because the port limit rejects once + // the incremented connection count reaches the configured limit. With a + // zero baseline and N = limit + 1 test-owned clients, exactly the last + // two requests should be rejected. + if (limit != 0) + BEAST_EXPECT(env.disconnectClient()); + + // For nonzero limits, go one past the limit. The port rejects at the + // limit, not only above it, so this yields the last two clients + // failing. For zero limit, pick an arbitrary nonzero number of clients + // and expect them all to succeed. int const testTo = (limit == 0) ? 50 : limit + 1; - while (connectionCount < testTo) + while (static_cast(clients.size()) < testTo) { clients.emplace_back(ip::tcp::socket{ios}, boost::beast::multi_buffer{}); async_connect(clients.back().first, it, yield[ec]); @@ -597,19 +610,24 @@ class ServerStatus_test : public beast::unit_test::Suite, public beast::test::En auto req = makeHTTPRequest(ip, port, to_string(jr), {}); async_write(clients.back().first, req, yield[ec]); BEAST_EXPECT(!ec); - ++connectionCount; } - int readCount = 0; + int successfulReads = 0; for (auto& [soc, buf] : clients) { boost::beast::http::response resp; async_read(soc, buf, resp, yield[ec]); - ++readCount; - // expect the reads to fail for the clients that connected at or - // above the limit. If limit is 0, all reads should succeed - BEAST_EXPECT((limit == 0 || readCount < limit - 1) ? (!ec) : bool(ec)); + if (!ec) + ++successfulReads; } + + // This test cares about the exact number of accepted requests, not which + // specific client observed the rejection. With a zero baseline (the + // hidden Env client dropped above), the server accepts until the + // connection count reaches the limit: all clients for limit 0, else + // limit - 1 of the limit + 1 clients (the last two are rejected). + int const expectedReads = (limit == 0) ? static_cast(clients.size()) : limit - 1; + BEAST_EXPECT(successfulReads == expectedReads); } void From ed4c5e141c3057659ccbe384c2abe465090ba8b0 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:39:27 +0200 Subject: [PATCH 11/12] test: Drop obsolete amendment-disabled cash-basis dispatch cases Now that cashBasisEnabled only checks the Vault's LEVersion, dispatch no longer depends on the amendment flag. The "amendment disabled picks Accrual" sub-cases fed a CashBasis-versioned vault while disabling the amendment, a combination that can no longer occur (LEVersion is fixed at Vault creation) and that the current dispatch correctly ignores, so they failed. The remaining legacy-vault/cash-basis-vault cases already cover the dispatch logic. --- src/test/app/LendingHelpers_test.cpp | 43 ---------------------------- 1 file changed, 43 deletions(-) diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index 94f5813bd8..1235920fab 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -1668,18 +1668,6 @@ class LendingHelpers_test : public beast::unit_test::Suite auto const legacyVault = makeVaultSle(); auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); - { - testcase("loanOriginationDeltas dispatcher: amendment disabled picks Accrual"); - Env env{*this}; - env.disableFeature(featureLendingProtocolV1_1); - auto const deltas = - loanOriginationDeltas(cashBasisVault, 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, legacy vault picks " @@ -1718,18 +1706,6 @@ class LendingHelpers_test : public beast::unit_test::Suite auto const legacyVault = makeVaultSle(std::nullopt, vaultMaximum, vaultTotal); auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis, vaultMaximum, vaultTotal); - { - testcase( - "loanOriginationExceedsVaultMaximum dispatcher: amendment disabled picks " - "Accrual"); - Env env{*this}; - env.disableFeature(featureLendingProtocolV1_1); - BEAST_EXPECT( - loanOriginationExceedsVaultMaximum(cashBasisVault, vaultTotal, interestDue) == - xrpl::Accrual::loanOriginationExceedsVaultMaximum( - vaultMaximum, vaultTotal, interestDue)); - } - { testcase( "loanOriginationExceedsVaultMaximum dispatcher: amendment enabled, legacy vault " @@ -1760,15 +1736,6 @@ class LendingHelpers_test : public beast::unit_test::Suite auto const legacyVault = makeVaultSle(); auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); - { - 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(cashBasisVault, sle) == xrpl::Accrual::loanVaultExposure(sle)); - } - { testcase("loanVaultExposure dispatcher: amendment enabled, legacy vault picks Accrual"); Env const env{*this}; @@ -1803,16 +1770,6 @@ class LendingHelpers_test : public beast::unit_test::Suite auto const legacyVault = makeVaultSle(); auto const cashBasisVault = makeVaultSle(VaultVersion::CashBasis); - { - testcase("loanPaymentDeltas dispatcher: amendment disabled picks Accrual"); - Env env{*this}; - env.disableFeature(featureLendingProtocolV1_1); - auto const deltas = loanPaymentDeltas(cashBasisVault, 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, legacy vault picks Accrual"); Env const env{*this}; From 59c779fc01d1d506285a3c1510d0741aeff321db Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:50:08 +0200 Subject: [PATCH 12/12] refactor: Use static_cast instead of switch in getVaultVersion Address PR review comment: VaultVersion's enumerators already have explicit values, so static_cast(leVersion) avoids repeating them as switch-case magic numbers. Out-of-range values still hit the UNREACHABLE guard. --- src/libxrpl/ledger/helpers/VaultHelpers.cpp | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 1ac4abdca3..78f64d2077 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -14,6 +14,7 @@ #include #include +#include namespace xrpl { @@ -145,18 +146,15 @@ getVaultVersion(SLE::const_ref vault) if (!vault->isFieldPresent(sfLEVersion)) return VaultVersion::Legacy; - switch (vault->at(sfLEVersion)) + auto const version = vault->at(sfLEVersion); + if (version > std::to_underlying(VaultVersion::CashBasis)) { - case 0: - return VaultVersion::Legacy; - case 1: - return VaultVersion::CashBasis; - default: - // LCOV_EXCL_START - UNREACHABLE("xrpl::getVaultVersion : invalid vault version"); - return VaultVersion::Legacy; - // LCOV_EXCL_STOP + // LCOV_EXCL_START + UNREACHABLE("xrpl::getVaultVersion : invalid vault version"); + return VaultVersion::Legacy; + // LCOV_EXCL_STOP } + return static_cast(version); } } // namespace xrpl