mirror of
https://github.com/XRPLF/rippled.git
synced 2026-06-11 04:36:49 +00:00
Compare commits
40 Commits
bthomee/no
...
a1q123456/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b25bdc0d | ||
|
|
f47ca5654d | ||
|
|
947f56e677 | ||
|
|
f95f87d673 | ||
|
|
c53ea3c11d | ||
|
|
0bc4be2b9c | ||
|
|
bb0a09ae21 | ||
|
|
e74a24bced | ||
|
|
4c665f1678 | ||
|
|
d94232007f | ||
|
|
df8bfbe5af | ||
|
|
347d1a19ef | ||
|
|
6466e94bb8 | ||
|
|
d65fab27a1 | ||
|
|
b5d25c5ab1 | ||
|
|
7222150095 | ||
|
|
afca681a86 | ||
|
|
668e677dff | ||
|
|
3101619029 | ||
|
|
b42fbeaaeb | ||
|
|
a67da5c2ed | ||
|
|
d2f23b2f5b | ||
|
|
4067e5025f | ||
|
|
ed4330a7d6 | ||
|
|
feba605998 | ||
|
|
b322097529 | ||
|
|
e159d27373 | ||
|
|
ba53026006 | ||
|
|
34773080df | ||
|
|
3c3bd75991 | ||
|
|
7459fe454d | ||
|
|
106bf48725 | ||
|
|
74c968d4e3 | ||
|
|
167147281c | ||
|
|
ba60306610 | ||
|
|
6674500896 | ||
|
|
c5d7ebe93d | ||
|
|
d0b5ca9dab | ||
|
|
5e51893e9b | ||
|
|
3422c11d02 |
@@ -264,6 +264,49 @@ computeFullPaymentInterest(
|
||||
std::uint32_t startDate,
|
||||
TenthBips32 closeInterestRate);
|
||||
|
||||
/** Whether to use the proportional (new) default cover formula.
|
||||
*
|
||||
* Returns true when featureDefaultCoverOptimization is enabled AND the broker
|
||||
* does not carry the deprecated sfCoverRateLiquidation field.
|
||||
*/
|
||||
inline bool
|
||||
useProportionalDefaultCover(Rules const& rules, std::shared_ptr<SLE const> const& brokerSle)
|
||||
{
|
||||
return rules.enabled(featureDefaultCoverOptimization) &&
|
||||
!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 featureDefaultCoverOptimization is enabled and whether the broker still
|
||||
* carries the deprecated sfCoverRateLiquidation value.
|
||||
*
|
||||
* @param useProportionalFormula true when featureDefaultCoverOptimization 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
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
// Add new amendments to the top of this list.
|
||||
// Keep it sorted in reverse chronological order.
|
||||
|
||||
XRPL_FEATURE(DefaultCoverOptimization, Supported::No, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (Cleanup3_2_0, Supported::Yes, VoteBehavior::DefaultNo)
|
||||
XRPL_FEATURE(MPTokensV2, Supported::No, VoteBehavior::DefaultNo)
|
||||
XRPL_FIX (Cleanup3_1_3, Supported::Yes, VoteBehavior::DefaultYes)
|
||||
|
||||
@@ -518,6 +518,7 @@ LEDGER_ENTRY(ltLOAN_BROKER, 0x0088, LoanBroker, loan_broker, ({
|
||||
{sfDebtMaximum, SoeDefault},
|
||||
{sfCoverAvailable, SoeDefault},
|
||||
{sfCoverRateMinimum, SoeDefault},
|
||||
// Deprecated by featureDefaultCoverOptimization
|
||||
{sfCoverRateLiquidation, SoeDefault},
|
||||
}))
|
||||
|
||||
|
||||
@@ -107,6 +107,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)
|
||||
|
||||
@@ -960,6 +960,7 @@ TRANSACTION(ttLOAN_BROKER_SET, 74, LoanBrokerSet,
|
||||
{sfManagementFeeRate, SoeOptional},
|
||||
{sfDebtMaximum, SoeOptional},
|
||||
{sfCoverRateMinimum, SoeOptional},
|
||||
// Deprecated by featureDefaultCoverOptimization
|
||||
{sfCoverRateLiquidation, SoeOptional},
|
||||
}))
|
||||
|
||||
|
||||
@@ -130,6 +130,50 @@ isRounded(Asset const& asset, Number const& value, std::int32_t scale)
|
||||
roundToAsset(asset, value, scale, Number::RoundingMode::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 const mg(Number::RoundingMode::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
|
||||
|
||||
@@ -67,6 +67,9 @@ LoanBrokerSet::preflight(PreflightContext const& ctx)
|
||||
return temINVALID;
|
||||
}
|
||||
|
||||
// sfCoverRateLiquidation is deprecated by featureDefaultCoverOptimization;
|
||||
// only enforce consistency when the amendment is not enabled.
|
||||
if (!ctx.rules.enabled(featureDefaultCoverOptimization))
|
||||
{
|
||||
auto const minimumZero = tx[~sfCoverRateMinimum].value_or(0) == 0;
|
||||
auto const liquidationZero = tx[~sfCoverRateLiquidation].value_or(0) == 0;
|
||||
@@ -267,7 +270,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(featureDefaultCoverOptimization))
|
||||
broker->at(sfCoverRateLiquidation) = *coverLiq;
|
||||
|
||||
view.insert(broker);
|
||||
|
||||
@@ -162,25 +162,16 @@ LoanManage::defaultLoan(
|
||||
|
||||
// Apply the First-Loss Capital to the Default Amount
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
TenthBips32 const coverRateLiquidation{brokerSle->at(sfCoverRateLiquidation)};
|
||||
auto const defaultCovered = [&]() {
|
||||
// Always round the minimum required up.
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
auto const minimumCover = tenthBipsOfValue(brokerDebtTotalProxy.value(), coverRateMinimum);
|
||||
// Round the liquidation amount up, too
|
||||
auto const 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, coverRateLiquidation), 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;
|
||||
|
||||
|
||||
@@ -1470,6 +1470,199 @@ class LendingHelpers_test : public beast::unit_test::Suite
|
||||
Number{-18304, -5}));
|
||||
}
|
||||
|
||||
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
|
||||
testCanApplyToBrokerCover()
|
||||
@@ -1572,6 +1765,7 @@ public:
|
||||
testComputePaymentFactorNearZeroRate();
|
||||
testComputeOverpaymentComponents();
|
||||
testComputeInterestAndFeeParts();
|
||||
testComputeDefaultCovered();
|
||||
testCanApplyToBrokerCover();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include <xrpl/core/ServiceRegistry.h>
|
||||
#include <xrpl/ledger/ApplyView.h>
|
||||
#include <xrpl/ledger/OpenView.h>
|
||||
#include <xrpl/ledger/helpers/LendingHelpers.h>
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
@@ -538,7 +539,7 @@ class LoanBroker_test : public beast::unit_test::Suite
|
||||
}
|
||||
|
||||
void
|
||||
testLifecycle()
|
||||
testLifecycle(FeatureBitset features)
|
||||
{
|
||||
testcase("Lifecycle");
|
||||
using namespace jtx;
|
||||
@@ -688,18 +689,26 @@ class LoanBroker_test : public beast::unit_test::Suite
|
||||
Ter(temINVALID));
|
||||
// Cover: zero min, non-zero liquidation - implicit and
|
||||
// explicit zero values.
|
||||
env(set(evan, vault.vaultID), kCoverRateLiquidation(kMaxCoverRate), Ter(temINVALID));
|
||||
env(set(evan, vault.vaultID),
|
||||
kCoverRateLiquidation(kMaxCoverRate),
|
||||
env.enabled(featureDefaultCoverOptimization) ? Ter(tecNO_PERMISSION)
|
||||
: Ter(temINVALID));
|
||||
env(set(evan, vault.vaultID),
|
||||
kCoverRateMinimum(tenthBipsZero),
|
||||
kCoverRateLiquidation(kMaxCoverRate),
|
||||
Ter(temINVALID));
|
||||
env.enabled(featureDefaultCoverOptimization) ? Ter(tecNO_PERMISSION)
|
||||
: Ter(temINVALID));
|
||||
// Cover: non-zero min, zero liquidation - implicit and
|
||||
// explicit zero values.
|
||||
env(set(evan, vault.vaultID), kCoverRateMinimum(kMaxCoverRate), Ter(temINVALID));
|
||||
env(set(evan, vault.vaultID),
|
||||
kCoverRateMinimum(kMaxCoverRate),
|
||||
env.enabled(featureDefaultCoverOptimization) ? Ter(tecNO_PERMISSION)
|
||||
: Ter(temINVALID));
|
||||
env(set(evan, vault.vaultID),
|
||||
kCoverRateMinimum(kMaxCoverRate),
|
||||
kCoverRateLiquidation(tenthBipsZero),
|
||||
Ter(temINVALID));
|
||||
env.enabled(featureDefaultCoverOptimization) ? Ter(tecNO_PERMISSION)
|
||||
: Ter(temINVALID));
|
||||
// sfDebtMaximum: good value, bad account
|
||||
env(set(evan, vault.vaultID), kDebtMaximum(Number(0)), Ter(tecNO_PERMISSION));
|
||||
// sfDebtMaximum: overflow
|
||||
@@ -827,7 +836,15 @@ class LoanBroker_test : public beast::unit_test::Suite
|
||||
// Extra checks
|
||||
BEAST_EXPECT(broker->at(sfManagementFeeRate) == 123);
|
||||
BEAST_EXPECT(broker->at(sfCoverRateMinimum) == 100);
|
||||
BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 200);
|
||||
if (useProportionalDefaultCover(env.current()->rules(), broker))
|
||||
{
|
||||
BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
BEAST_EXPECT(broker->at(sfCoverRateLiquidation) == 200);
|
||||
}
|
||||
|
||||
BEAST_EXPECT(broker->at(sfDebtMaximum) == Number(9));
|
||||
BEAST_EXPECT(checkVL(broker->at(sfData), testData));
|
||||
},
|
||||
@@ -2250,7 +2267,8 @@ public:
|
||||
testLoanBrokerCoverDepositNullVault();
|
||||
|
||||
testDisabled();
|
||||
testLifecycle();
|
||||
testLifecycle(all_);
|
||||
testLifecycle(all_ - featureDefaultCoverOptimization);
|
||||
testInvalidLoanBrokerCoverClawback();
|
||||
testInvalidLoanBrokerCoverDeposit();
|
||||
testInvalidLoanBrokerCoverWithdraw();
|
||||
|
||||
@@ -2041,14 +2041,15 @@ protected:
|
||||
: std::max(
|
||||
broker.vaultScale(env), state.principalOutstanding.exponent())));
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
auto const defaultAmount = roundToAsset(
|
||||
auto const totalDefaultAmount = state.totalValue - state.managementFeeOutstanding;
|
||||
auto const defaultAmount = computeDefaultCovered(
|
||||
useProportionalDefaultCover(env.current()->rules(), brokerSle),
|
||||
brokerSle->at(~sfCoverRateLiquidation).value_or(0),
|
||||
brokerSle->at(sfCoverAvailable),
|
||||
broker.asset,
|
||||
std::min(
|
||||
tenthBipsOfValue(
|
||||
tenthBipsOfValue(
|
||||
brokerSle->at(sfDebtTotal), broker.params.coverRateMin),
|
||||
broker.params.coverRateLiquidation),
|
||||
state.totalValue - state.managementFeeOutstanding),
|
||||
totalDefaultAmount,
|
||||
brokerSle->at(sfDebtTotal),
|
||||
broker.params.coverRateMin,
|
||||
state.loanScale);
|
||||
return std::make_pair(defaultAmount, brokerSle->at(sfOwner));
|
||||
}
|
||||
@@ -7232,9 +7233,11 @@ protected:
|
||||
|
||||
// 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) =
|
||||
// + interest). Old 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
|
||||
// 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
|
||||
@@ -7293,11 +7296,38 @@ protected:
|
||||
auto const afterFirstDebtTotal = brokerSle->at(sfDebtTotal);
|
||||
auto const afterFirstCoverAvailable = brokerSle->at(sfCoverAvailable);
|
||||
|
||||
// DebtTotal should have decreased by Loan A's debt
|
||||
BEAST_EXPECT(afterFirstDebtTotal == 50'134);
|
||||
if (useProportionalDefaultCover(env.current()->rules(), brokerSle))
|
||||
{
|
||||
// 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
|
||||
|
||||
// CoverAvailable should have decreased significantly
|
||||
BEAST_EXPECT(afterFirstCoverAvailable == 946);
|
||||
// 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
|
||||
BEAST_EXPECT(afterFirstCoverAvailable == 10'973);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 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 (~52,067),
|
||||
// leaving only Loan B's debt (~50,134).
|
||||
BEAST_EXPECT(afterFirstDebtTotal == 50'134);
|
||||
|
||||
// CoverAvailable should have decreased significantly
|
||||
BEAST_EXPECT(afterFirstCoverAvailable == 946);
|
||||
}
|
||||
|
||||
env(manage(lender, loanBKeylet.key, tfLoanDefault), Ter(tesSUCCESS));
|
||||
|
||||
@@ -7309,7 +7339,22 @@ protected:
|
||||
|
||||
BEAST_EXPECT(afterSecondDebtTotal == 0);
|
||||
|
||||
BEAST_EXPECT(afterSecondCoverAvailable == 0);
|
||||
if (useProportionalDefaultCover(env.current()->rules(), brokerSle))
|
||||
{
|
||||
// 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
|
||||
{
|
||||
// Global default cover (old formula):
|
||||
// Only 946 remains to cover Loan B's DefaultAmount (~50,134)
|
||||
// Result: CoverAvailable = 0 (fully depleted)
|
||||
BEAST_EXPECT(afterSecondCoverAvailable == 0);
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
@@ -8702,6 +8747,200 @@ protected:
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
testCoverRateLiquidationAmendmentGating(FeatureBitset const& features)
|
||||
{
|
||||
testcase("CoverRateLiquidation amendment gating");
|
||||
|
||||
using namespace jtx;
|
||||
using namespace loanBroker;
|
||||
|
||||
auto const coverRateLiqValue = percentageToTenthBips(25);
|
||||
|
||||
{
|
||||
Env env(*this, features);
|
||||
|
||||
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))
|
||||
{
|
||||
bool const withAmendment = env.enabled(featureDefaultCoverOptimization);
|
||||
if (withAmendment)
|
||||
{
|
||||
// When featureDefaultCoverOptimization IS enabled,
|
||||
// sfCoverRateLiquidation should NOT be recorded on the broker.
|
||||
BEAST_EXPECT(!brokerSle->isFieldPresent(sfCoverRateLiquidation));
|
||||
}
|
||||
else
|
||||
{
|
||||
// When featureDefaultCoverOptimization is NOT enabled,
|
||||
// sfCoverRateLiquidation should be recorded on the broker.
|
||||
BEAST_EXPECT(brokerSle->isFieldPresent(sfCoverRateLiquidation));
|
||||
BEAST_EXPECT(
|
||||
brokerSle->at(sfCoverRateLiquidation) == coverRateLiqValue.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
kInterestRate(TenthBips32(500)), // 5 %
|
||||
kPaymentTotal(12),
|
||||
loan::kPaymentInterval(loanPaymentInterval),
|
||||
loan::kGracePeriod(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_ - featureDefaultCoverOptimization);
|
||||
|
||||
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(featureDefaultCoverOptimization);
|
||||
env.close();
|
||||
|
||||
BEAST_EXPECT(env.enabled(featureDefaultCoverOptimization));
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
void
|
||||
runAmendmentIndependent()
|
||||
{
|
||||
@@ -8726,6 +8965,9 @@ protected:
|
||||
testBugInterestDueDeltaCrash();
|
||||
testFullLifecycleVaultPnLNearZeroRate();
|
||||
testLoanSetNearZeroInterestRateSucceeds();
|
||||
|
||||
// Default cover optimization
|
||||
testCoverRateLiquidationBackwardsCompat();
|
||||
}
|
||||
|
||||
// Tests run under each entry in amendmentCombinations().
|
||||
@@ -8780,6 +9022,9 @@ protected:
|
||||
testLoanPayBrokerOwnerUnauthorizedMPT(features);
|
||||
testLoanPayBrokerOwnerNoPermissionedDomainMPT(features);
|
||||
testLoanSetBrokerOwnerNoPermissionedDomainMPT(features);
|
||||
|
||||
// Default cover optimization
|
||||
testCoverRateLiquidationAmendmentGating(features);
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
Reference in New Issue
Block a user