From 0bc4be2b9cc51cfbc8b228d77a529e80aee1ff09 Mon Sep 17 00:00:00 2001 From: JCW Date: Thu, 26 Mar 2026 23:56:54 +0000 Subject: [PATCH] Fix PR comments Signed-off-by: JCW --- .../xrpl/protocol/detail/ledger_entries.macro | 1 + include/xrpl/protocol/detail/sfields.macro | 1 + .../xrpl/protocol/detail/transactions.macro | 1 + .../tx/transactors/lending/LendingHelpers.h | 43 +++ .../tx/transactors/lending/LendingHelpers.cpp | 44 +++ .../tx/transactors/lending/LoanBrokerSet.cpp | 6 +- .../tx/transactors/lending/LoanManage.cpp | 43 +-- src/test/app/LendingHelpers_test.cpp | 194 ++++++++++++ src/test/app/Loan_test.cpp | 293 +++++++++++++++--- 9 files changed, 544 insertions(+), 82 deletions(-) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 216f404bec..f6e532cfdf 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -514,6 +514,7 @@ LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({ {sfDebtMaximum, soeDEFAULT}, {sfCoverAvailable, soeDEFAULT}, {sfCoverRateMinimum, soeDEFAULT}, + // Deprecated by featureLendingProtocolV1_1 {sfCoverRateLiquidation, soeDEFAULT}, })) diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 712cf568af..273e773958 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -108,6 +108,7 @@ TYPED_SFIELD(sfPaymentRemaining, UINT32, 59) TYPED_SFIELD(sfPaymentTotal, UINT32, 60) TYPED_SFIELD(sfLoanSequence, UINT32, 61) TYPED_SFIELD(sfCoverRateMinimum, UINT32, 62) // 1/10 basis points (bips) +// Deprecated by featureLendingProtocolV1_1 TYPED_SFIELD(sfCoverRateLiquidation, UINT32, 63) // 1/10 basis points (bips) TYPED_SFIELD(sfOverpaymentFee, UINT32, 64) // 1/10 basis points (bips) TYPED_SFIELD(sfInterestRate, UINT32, 65) // 1/10 basis points (bips) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 9cc56f8993..ed037a27cb 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -960,6 +960,7 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet, {sfManagementFeeRate, soeOPTIONAL}, {sfDebtMaximum, soeOPTIONAL}, {sfCoverRateMinimum, soeOPTIONAL}, + // Deprecated by featureLendingProtocolV1_1 {sfCoverRateLiquidation, soeOPTIONAL}, })) diff --git a/include/xrpl/tx/transactors/lending/LendingHelpers.h b/include/xrpl/tx/transactors/lending/LendingHelpers.h index 8dd6866ac3..49a9ad546b 100644 --- a/include/xrpl/tx/transactors/lending/LendingHelpers.h +++ b/include/xrpl/tx/transactors/lending/LendingHelpers.h @@ -219,6 +219,49 @@ computeFullPaymentInterest( std::uint32_t startDate, TenthBips32 closeInterestRate); +/** Whether to use the proportional (new) default cover formula. + * + * Returns true when featureLendingProtocolV1_1 is enabled AND the broker + * does not carry the deprecated sfCoverRateLiquidation field. + */ +inline bool +useProportionalDefaultCover(Rules const& rules, std::shared_ptr brokerSle) +{ + return rules.enabled(featureLendingProtocolV1_1) && + !brokerSle->isFieldPresent(sfCoverRateLiquidation); +} + +/** Compute the amount of First-Loss Capital seized to cover a defaulted loan. + * + * Selects between the old (global) and new (proportional) formula based on + * whether featureLendingProtocolV1_1 is enabled and whether the broker still + * carries the deprecated sfCoverRateLiquidation value. + * + * @param useProportionalFormula true when featureLendingProtocolV1_1 is + * enabled AND the broker has no + * sfCoverRateLiquidation. + * @param coverRateLiquidation The broker's CoverRateLiquidation in 1/10 + * bips. Only used by the old formula; ignored + * when \p useProportionalFormula is true. + * @param coverAvailable The broker's current CoverAvailable. + * @param vaultAsset The Vault's asset type (for rounding). + * @param totalDefaultAmount The loan's default amount (owed to the vault). + * @param brokerDebtTotal The broker's total debt before this default. + * @param coverRateMinimum The broker's CoverRateMinimum in 1/10 bips. + * @param loanScale The loan's rounding scale. + * @return The amount of cover seized, capped at \p coverAvailable. + */ +Number +computeDefaultCovered( + bool useProportionalFormula, + std::uint32_t coverRateLiquidation, + Number const& coverAvailable, + Asset const& vaultAsset, + Number const& totalDefaultAmount, + Number const& brokerDebtTotal, + TenthBips32 coverRateMinimum, + std::int32_t loanScale); + namespace detail { // These classes and functions should only be accessed by LendingHelper // functions and unit tests diff --git a/src/libxrpl/tx/transactors/lending/LendingHelpers.cpp b/src/libxrpl/tx/transactors/lending/LendingHelpers.cpp index ad4cd8440d..a08010b6a5 100644 --- a/src/libxrpl/tx/transactors/lending/LendingHelpers.cpp +++ b/src/libxrpl/tx/transactors/lending/LendingHelpers.cpp @@ -64,6 +64,50 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale) roundToAsset(asset, value, scale, Number::upward); } +Number +computeDefaultCovered( + bool useProportionalFormula, + std::uint32_t coverRateLiquidation, + Number const& coverAvailable, + Asset const& vaultAsset, + Number const& totalDefaultAmount, + Number const& brokerDebtTotal, + TenthBips32 coverRateMinimum, + std::int32_t loanScale) +{ + // Always round the minimum required up. + NumberRoundModeGuard mg(Number::upward); + Number covered; + + if (useProportionalFormula) + { + // New formula: DefaultCovered = min(DefaultAmount × CoverRateMinimum, + // CoverAvailable) + covered = roundToAsset( + vaultAsset, tenthBipsOfValue(totalDefaultAmount, coverRateMinimum), loanScale); + } + else + { + // Old formula (deprecated by featureLendingProtocolV1_1): + // Kept for backwards compatibility with brokers that still carry + // sfCoverRateLiquidation. + auto const minimumCover = tenthBipsOfValue(brokerDebtTotal, coverRateMinimum); + covered = roundToAsset( + vaultAsset, + /* + * This formula is from the XLS-66 spec, section 3.2.3.2 (State + * Changes), specifically "if the `tfLoanDefault` flag is set" / + * "Apply the First-Loss Capital to the Default Amount" + */ + std::min( + tenthBipsOfValue(minimumCover, TenthBips32{coverRateLiquidation}), + totalDefaultAmount), + loanScale); + } + + return std::min(covered, coverAvailable); +} + namespace detail { void diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp index f8813ddbef..0241a64e76 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp @@ -49,6 +49,9 @@ LoanBrokerSet::preflight(PreflightContext const& ctx) return temINVALID; } + // sfCoverRateLiquidation is deprecated by featureLendingProtocolV1_1; + // only enforce consistency when the amendment is not enabled. + if (!ctx.rules.enabled(featureLendingProtocolV1_1)) { auto const minimumZero = tx[~sfCoverRateMinimum].value_or(0) == 0; auto const liquidationZero = tx[~sfCoverRateLiquidation].value_or(0) == 0; @@ -249,7 +252,8 @@ LoanBrokerSet::doApply() broker->at(sfDebtMaximum) = *debtMax; if (auto const coverMin = tx[~sfCoverRateMinimum]) broker->at(sfCoverRateMinimum) = *coverMin; - if (auto const coverLiq = tx[~sfCoverRateLiquidation]) + if (auto const coverLiq = tx[~sfCoverRateLiquidation]; + coverLiq && !view.rules().enabled(featureLendingProtocolV1_1)) broker->at(sfCoverRateLiquidation) = *coverLiq; view.insert(broker); diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index 0360e63a7c..6a3c9571a9 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -141,41 +141,16 @@ LoanManage::defaultLoan( // Apply the First-Loss Capital to the Default Amount TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)}; - auto const coverRateLiquidation{brokerSle->at(~sfCoverRateLiquidation)}; - auto const defaultCovered = [&]() { - // Always round the minimum required up. - NumberRoundModeGuard mg(Number::upward); - Number covered; - - if (view.rules().enabled(featureLendingProtocolV1_1) && !coverRateLiquidation) - { - // New formula: DefaultCovered = min(DefaultAmount × CoverRateMinimum, CoverAvailable) - // Round the liquidation amount up - covered = roundToAsset( - vaultAsset, tenthBipsOfValue(totalDefaultAmount, coverRateMinimum), loanScale); - } - else - { - auto const minimumCover = - tenthBipsOfValue(brokerDebtTotalProxy.value(), coverRateMinimum); - // Round the liquidation amount up - covered = roundToAsset( - vaultAsset, - /* - * This formula is from the XLS-66 spec, section 3.2.3.2 (State - * Changes), specifically "if the `tfLoanDefault` flag is set" / - * "Apply the First-Loss Capital to the Default Amount" - */ - std::min( - tenthBipsOfValue(minimumCover, TenthBips32{coverRateLiquidation.value_or(0)}), - totalDefaultAmount), - loanScale); - } - - auto const coverAvailable = *brokerSle->at(sfCoverAvailable); - return std::min(covered, coverAvailable); - }(); + auto const defaultCovered = computeDefaultCovered( + useProportionalDefaultCover(view.rules(), brokerSle), + brokerSle->at(sfCoverRateLiquidation), + *brokerSle->at(sfCoverAvailable), + vaultAsset, + totalDefaultAmount, + brokerDebtTotalProxy.value(), + coverRateMinimum, + loanScale); auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered; diff --git a/src/test/app/LendingHelpers_test.cpp b/src/test/app/LendingHelpers_test.cpp index 3b6fd73e32..c4ec22fe3b 100644 --- a/src/test/app/LendingHelpers_test.cpp +++ b/src/test/app/LendingHelpers_test.cpp @@ -1185,6 +1185,199 @@ class LendingHelpers_test : public beast::unit_test::suite to_string(actualPaymentParts.valueChange - actualPaymentParts.interestPaid)); } + void + testComputeDefaultCovered() + { + testcase("computeDefaultCovered"); + + using namespace jtx; + + // ---- Common parameters ---- + Asset const asset{xrpIssue()}; + std::int32_t const loanScale = 1; + // coverRateLiquidation value used by old-formula tests (100%). + std::uint32_t const covRateLiq = 100'000; + + // ---- Test 1: New formula basic ---- + // DefaultCovered = min(DefaultAmount × CoverRateMinimum, + // CoverAvailable) + // 100,000 × 20% = 20,000; min(20,000, 50,000) = 20,000 + { + auto result = computeDefaultCovered( + true, // useProportionalFormula + 0, // coverRateLiquidation (unused) + Number{50'000}, // coverAvailable + asset, + Number{100'000}, // totalDefaultAmount + Number{200'000}, // brokerDebtTotal (unused) + TenthBips32{20'000}, // 20% + loanScale); + BEAST_EXPECT(result == Number{20'000}); + } + + // ---- Test 2: New formula capped by CoverAvailable ---- + // 100,000 × 50% = 50,000; min(50,000, 10,000) = 10,000 + { + auto result = computeDefaultCovered( + true, + 0, + Number{10'000}, + asset, + Number{100'000}, + Number{200'000}, + TenthBips32{50'000}, // 50% + loanScale); + BEAST_EXPECT(result == Number{10'000}); + } + + // ---- Test 3: Old formula basic ---- + // min(CovRateLiq × (CovRateMin × BrokerDebtTotal), DefaultAmount) + // minimumCover = 200,000 × 20% = 40,000 + // covered = min(100% × 40,000, 50,000) = 40,000 + // min(40,000, 100,000) = 40,000 + { + auto result = computeDefaultCovered( + false, // old formula + covRateLiq, // 100% + Number{100'000}, + asset, + Number{50'000}, + Number{200'000}, + TenthBips32{20'000}, + loanScale); + BEAST_EXPECT(result == Number{40'000}); + } + + // ---- Test 4: Old formula capped by DefaultAmount ---- + // minimumCover = 200,000 × 50% = 100,000 + // covered = min(100% × 100,000, 30,000) = 30,000 + // min(30,000, 500,000) = 30,000 + { + auto result = computeDefaultCovered( + false, + covRateLiq, + Number{500'000}, + asset, + Number{30'000}, + Number{200'000}, + TenthBips32{50'000}, + loanScale); + BEAST_EXPECT(result == Number{30'000}); + } + + // ---- Test 5: Old formula capped by CoverAvailable ---- + // minimumCover = 200,000 × 20% = 40,000 + // covered = min(100% × 40,000, 100,000) = 40,000 + // min(40,000, 5,000) = 5,000 + { + auto result = computeDefaultCovered( + false, + covRateLiq, + Number{5'000}, // small CoverAvailable + asset, + Number{100'000}, + Number{200'000}, + TenthBips32{20'000}, + loanScale); + BEAST_EXPECT(result == Number{5'000}); + } + + // ---- Test 6: Backwards compatibility ---- + // useProportionalFormula = false even though the amendment is + // enabled, because the broker was created before the amendment + // and still carries sfCoverRateLiquidation. The caller passes + // false in this case; we verify the old formula is used. + // minimumCover = 200,000 × 20% = 40,000 + // covered = min(100% × 40,000, 50,000) = 40,000 + // min(40,000, 100,000) = 40,000 + { + auto result = computeDefaultCovered( + false, // old formula (backwards compat) + covRateLiq, + Number{100'000}, + asset, + Number{50'000}, + Number{200'000}, + TenthBips32{20'000}, + loanScale); + BEAST_EXPECT(result == Number{40'000}); + } + + // ---- Test 7: New vs old produce different results ---- + // Same inputs, different formula selection → different outputs. + { + auto resultNew = computeDefaultCovered( + true, + 0, + Number{100'000}, + asset, + Number{50'000}, + Number{200'000}, + TenthBips32{20'000}, + loanScale); + + auto resultOld = computeDefaultCovered( + false, + covRateLiq, + Number{100'000}, + asset, + Number{50'000}, + Number{200'000}, + TenthBips32{20'000}, + loanScale); + + // New: 50,000 × 20% = 10,000 + BEAST_EXPECT(resultNew == Number{10'000}); + // Old: min(100% × (20% × 200,000), 50,000) + // = min(40,000, 50,000) = 40,000 + BEAST_EXPECT(resultOld == Number{40'000}); + BEAST_EXPECT(resultNew != resultOld); + } + + // ---- Test 8: Zero CoverAvailable ---- + { + auto result = computeDefaultCovered( + true, + 0, + Number{0}, + asset, + Number{100'000}, + Number{200'000}, + TenthBips32{20'000}, + loanScale); + BEAST_EXPECT(result == Number{0}); + } + + // ---- Test 9: Zero DefaultAmount ---- + { + auto result = computeDefaultCovered( + true, + 0, + Number{50'000}, + asset, + Number{0}, + Number{200'000}, + TenthBips32{20'000}, + loanScale); + BEAST_EXPECT(result == Number{0}); + } + + // ---- Test 10: Zero CoverRateMinimum (new formula) ---- + // 100,000 × 0% = 0 + { + auto result = computeDefaultCovered( + true, + 0, + Number{50'000}, + asset, + Number{100'000}, + Number{200'000}, + TenthBips32{0}, + loanScale); + BEAST_EXPECT(result == Number{0}); + } + } + public: void run() override @@ -1205,6 +1398,7 @@ public: testComputePaymentFactor(); testComputeOverpaymentComponents(); testComputeInterestAndFeeParts(); + testComputeDefaultCovered(); } }; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 9177a0c797..4e7eca235a 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -1945,31 +1945,15 @@ protected: broker.vaultScale(env), state.principalOutstanding.exponent()))); NumberRoundModeGuard mg(Number::upward); auto const totalDefaultAmount = state.totalValue - state.managementFeeOutstanding; - auto const defaultAmount = [&] { - if (env.enabled(featureLendingProtocolV1_1)) - { - // DefaultCovered = min(DefaultAmount × CoverRateMinimum, CoverAvailable) - return roundToAsset( - broker.asset, - tenthBipsOfValue(totalDefaultAmount, broker.params.coverRateMin), - state.loanScale); - } - else - { - // From XLS-66 spec, section 3.2.3.2: - // DefaultCovered = min(DebtTotal × CoverRateMinimum × CoverRateLiquidation, - // DefaultAmount, CoverAvailable) - return roundToAsset( - broker.asset, - std::min( - tenthBipsOfValue( - tenthBipsOfValue( - brokerSle->at(sfDebtTotal), broker.params.coverRateMin), - broker.params.coverRateLiquidation), - state.totalValue - state.managementFeeOutstanding), - state.loanScale); - } - }(); + auto const defaultAmount = computeDefaultCovered( + useProportionalDefaultCover(env.current()->rules(), brokerSle), + brokerSle->at(~sfCoverRateLiquidation).value_or(0), + brokerSle->at(sfCoverAvailable), + broker.asset, + totalDefaultAmount, + brokerSle->at(sfDebtTotal), + broker.params.coverRateMin, + state.loanScale); return std::make_pair(defaultAmount, brokerSle->at(sfOwner)); } return std::make_pair(Number{}, AccountID{}); @@ -6916,11 +6900,13 @@ protected: }); auto const brokerKeylet = brokerInfo.brokerKeylet(); - // Create two identical loans: each 50,000 XRP principal (scaled down to - // avoid funding issues) Total DebtTotal will be ~100,000 XRP (principal - // + interest) Formula will calculate cover as: 100% × (20% × 100,000) = - // 20,000 XRP So we need FLC = 20,000 XRP to be fully consumed by first - // default + // Create two identical loans: each 50,000 XRP principal. + // Total BrokerDebtTotal will be ~104,201 (principal + interest + fees). + // Old formula: seizure = min(100% × (20% × BrokerDebtTotal), + // DefaultAmount) ≈ 20,054 — nearly depleting 21,000 FLC on the + // first default. + // New formula: seizure = DefaultAmount × 20% ≈ 10,027 — splitting + // FLC equitably across both defaults. auto const principalAmount = Number(50'000); auto const loanPaymentInterval = 2592000; // 30 days auto const loanGracePeriod = 604800; // 7 days @@ -6979,13 +6965,16 @@ protected: auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal); auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable); - if (env.enabled(featureLendingProtocolV1_1)) + if (useProportionalDefaultCover(env.current()->rules(), brokerSle)) { - // Proportional default cover - // Loan 1 Defaults: 20% of Loan A (50,134) = 10,027 seizure + // Proportional default cover (new formula): + // DefaultCovered = min(DefaultAmount × CoverRateMinimum, + // CoverAvailable) + // Loan A's DefaultAmount (~52,067) × 20% = 10,027 seizure // Result: CoverAvailable = 21,000 - 10,027 = 10,973 - // DebtTotal should have decreased by Loan A's debt (~52,067) + // DebtTotal should have decreased by Loan A's debt (~52,067), + // leaving only Loan B's debt (~50,134). BEAST_EXPECT(afterFirstDebtTotal == 50'134); // CoverAvailable should have decreased proportionally @@ -6993,10 +6982,16 @@ protected: } else { - // Loan 1 Defaults: 100% × (20% × 104,201) = 20,840 seizure - // Result: CoverAvailable = 21,000 - 20,840 = 160 + // Global default cover (old formula): + // DefaultCovered = min(CoverRateLiquidation × + // (CoverRateMinimum × BrokerDebtTotal), DefaultAmount) + // Pre-default BrokerDebtTotal (~104,201) × 20% = 20,840 + // then 100% × 20,840 = 20,840 + // but capped at DefaultAmount (~52,067), seizure = 20,054 + // Result: CoverAvailable = 21,000 - 20,054 = 946 - // DebtTotal should have decreased by Loan A's debt + // DebtTotal should have decreased by Loan A's debt (~52,067), + // leaving only Loan B's debt (~50,134). BEAST_EXPECT(afterFirstDebtTotal == 50'134); // CoverAvailable should have decreased significantly @@ -7016,22 +7011,20 @@ protected: // Both scenarios: DebtTotal should be 0 after both loans default BEAST_EXPECT(afterSecondDebtTotal == 0); - if (env.enabled(featureLendingProtocolV1_1)) + if (useProportionalDefaultCover(env.current()->rules(), brokerSle)) { - // Proportional default cover - // Loan 2 Defaults: 20% of Loan B (50,134) = 10,027 seizure - // Result: CoverAvailable = 10,973 - 10,027 = 946 (safety buffer remains) - - // Both loans are covered equitably with a safety buffer remaining + // Proportional default cover (new formula): + // Loan B's DefaultAmount (~50,134) × 20% = 10,027 seizure + // Result: CoverAvailable = 10,973 - 10,027 = 946 + // + // Both loans are covered equitably with a safety buffer remaining. BEAST_EXPECT(afterSecondCoverAvailable == 946); } else { - // Scenario A: Global Logic (Old Formula) - // Loan 2 Defaults: Only 160 remains to cover a 52,067 loss + // Global default cover (old formula): + // Only 946 remains to cover Loan B's DefaultAmount (~50,134) // Result: CoverAvailable = 0 (fully depleted) - - // CoverAvailable should be fully depleted BEAST_EXPECT(afterSecondCoverAvailable == 0); } } @@ -7166,6 +7159,210 @@ protected: attemptWithdrawShares(depositorB, sharesLpB, tesSUCCESS); } + void + testCoverRateLiquidationAmendmentGating() + { + testcase("CoverRateLiquidation amendment gating"); + + using namespace jtx; + using namespace loanBroker; + + auto const coverRateLiqValue = percentageToTenthBips(25); + + // When featureLendingProtocolV1_1 is NOT enabled, + // sfCoverRateLiquidation should be recorded on the broker. + { + Env env(*this, all - featureLendingProtocolV1_1); + + Account const lender{"lender"}; + env.fund(XRP(10'000'000), lender); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerParameters brokerParams{.coverRateLiquidation = coverRateLiqValue}; + BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(brokerSle->isFieldPresent(sfCoverRateLiquidation)); + BEAST_EXPECT(brokerSle->at(sfCoverRateLiquidation) == coverRateLiqValue.value()); + } + } + + // When featureLendingProtocolV1_1 IS enabled, + // sfCoverRateLiquidation should NOT be recorded on the broker. + { + Env env(*this, all); + + Account const lender{"lender"}; + env.fund(XRP(10'000'000), lender); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + + BrokerParameters brokerParams{.coverRateLiquidation = coverRateLiqValue}; + BrokerInfo broker{createVaultAndBroker(env, xrpAsset, lender, brokerParams)}; + + auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID)); + if (BEAST_EXPECT(brokerSle)) + { + BEAST_EXPECT(!brokerSle->isFieldPresent(sfCoverRateLiquidation)); + } + } + } + + void + testCoverRateLiquidationBackwardsCompat() + { + testcase("CoverRateLiquidation backwards compatibility on default"); + + // Verify that the default cover formula honours whether + // sfCoverRateLiquidation is present on the broker SLE, + // regardless of the amendment state: + // + // * A broker created BEFORE the amendment has the field → + // old formula (global) is used even after the amendment + // is enabled. + // + // * A broker created AFTER the amendment lacks the field → + // new formula (proportional) is used. + + using namespace jtx; + using namespace loan; + using namespace loanBroker; + + // ---- helpers shared by both sub-tests ---- + auto const coverRateMin = TenthBips32(20'000); // 20 % + auto const coverRateLiq = percentageToTenthBips(25); // 25 % (default) + auto const principalAmount = Number(50'000); + auto const loanPaymentInterval = 2'592'000; // 30 days + auto const loanGracePeriod = 604'800; // 7 days + + // Lambda that creates one loan, advances past the grace period, + // defaults it, and returns the CoverAvailable after default. + auto defaultOneLoan = [&](Env& env, + BrokerInfo const& brokerInfo, + Account const& lender, + Account const& borrower) -> Number { + auto const brokerKeylet = brokerInfo.brokerKeylet(); + + auto loanTx = env.jt( + set(borrower, brokerKeylet.key, principalAmount), + sig(sfCounterpartySignature, lender), + interestRate(TenthBips32(500)), // 5 % + paymentTotal(12), + loan::paymentInterval(loanPaymentInterval), + loan::gracePeriod(loanGracePeriod), + fee(XRP(10))); + env(loanTx); + env.close(); + + auto const loanKeylet = keylet::loan(brokerKeylet.key, 1); + auto loanSle = env.le(loanKeylet); + if (!BEAST_EXPECT(loanSle)) + return Number{-1}; + + auto const nextDue = loanSle->at(sfNextPaymentDueDate); + auto const grace = loanSle->at(sfGracePeriod); + env.close(std::chrono::seconds{nextDue + grace + 60}); + + env(manage(lender, loanKeylet.key, tfLoanDefault), ter(tesSUCCESS)); + env.close(); + + auto brokerSle = env.le(brokerKeylet); + if (!BEAST_EXPECT(brokerSle)) + return Number{-1}; + return brokerSle->at(sfCoverAvailable); + }; + + // ---- Sub-test A: pre-amendment broker (old formula) ---- + Number coverAfterOld; + { + // Start WITHOUT the amendment so the broker stores the field. + Env env(*this, all - featureLendingProtocolV1_1); + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset = xrpIssue(); + auto const brokerInfo = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = Number(200'000), + .debtMax = 0, + .coverRateMin = coverRateMin, + .coverDeposit = 21'000, + .managementFeeRate = TenthBips16(100), + .coverRateLiquidation = coverRateLiq, + }); + + // Confirm the field was stored. + { + auto sle = env.le(brokerInfo.brokerKeylet()); + BEAST_EXPECT(sle && sle->isFieldPresent(sfCoverRateLiquidation)); + } + + // Now enable the amendment – the broker keeps its field. + env.enableFeature(featureLendingProtocolV1_1); + env.close(); + + BEAST_EXPECT(env.enabled(featureLendingProtocolV1_1)); + + coverAfterOld = defaultOneLoan(env, brokerInfo, lender, borrower); + } + + // ---- Sub-test B: post-amendment broker (new formula) ---- + Number coverAfterNew; + { + Env env(*this, all); // amendment already enabled + + Account const lender{"lender"}; + Account const borrower{"borrower"}; + env.fund(XRP(1'000'000), lender, borrower); + env.close(); + + PrettyAsset const asset = xrpIssue(); + // Pass coverRateLiquidation in BrokerParameters, but the + // amendment-gated code will NOT store it on the SLE. + auto const brokerInfo = createVaultAndBroker( + env, + asset, + lender, + { + .vaultDeposit = Number(200'000), + .debtMax = 0, + .coverRateMin = coverRateMin, + .coverDeposit = 21'000, + .managementFeeRate = TenthBips16(100), + .coverRateLiquidation = coverRateLiq, + }); + + // Confirm the field was NOT stored. + { + auto sle = env.le(brokerInfo.brokerKeylet()); + BEAST_EXPECT(sle && !sle->isFieldPresent(sfCoverRateLiquidation)); + } + + coverAfterNew = defaultOneLoan(env, brokerInfo, lender, borrower); + } + + // Old (global) formula with 25% CoverRateLiquidation: + // min(25% × (20% × 50,134), 50,134) = min(2,507, 50,134) + // = 2,507 seized → CoverAvailable = 21,000 - 2,507 = 18,493 + BEAST_EXPECT(coverAfterOld == Number{18'493}); + + // New (proportional) formula: + // min(20% × 50,134, 21,000) = min(10,027, 21,000) + // = 10,027 seized → CoverAvailable = 21,000 - 10,027 = 10,973 + BEAST_EXPECT(coverAfterNew == Number{10'973}); + } + public: void run() override @@ -7221,6 +7418,8 @@ public: testLoanSetBrokerOwnerNoPermissionedDomainMPT(); testSequentialFLCDepletion(all - featureLendingProtocolV1_1); testSequentialFLCDepletion(all); + testCoverRateLiquidationAmendmentGating(); + testCoverRateLiquidationBackwardsCompat(); } };