feat: Apply FixedPrecision cover grid to LoanBroker first-loss capital

Cover deposit, withdraw, and clawback round at the posterior CoverAvailable exponent so optional inflows cannot coarsen past the Open zone, while outflows may re-fine. DebtMaximum and minimum cover use the vault base scale, and FixedPrecision withdraw/clawback skip the live-scale canApplyToBrokerCover guard that would reject a valid re-fine.
This commit is contained in:
Vito
2026-09-22 15:46:20 +02:00
parent 5e0e36f87d
commit bb820683cb
11 changed files with 522 additions and 52 deletions

View File

@@ -56,6 +56,46 @@ canApplyToBrokerCover(
beast::Journal j,
std::string_view logPrefix);
/**
* Return a LoanBroker's current live cover exponent.
*
* Legacy and CashBasis Vaults use the exponent of CoverAvailable.
* FixedPrecision Vaults floor that exponent at the Vault's base exponent.
*
* Reserved for fee redirection into cover. Cover deposit, withdraw, and
* clawback round at the posterior live exponent instead.
*/
[[nodiscard]] int
getBrokerCoverScale(SLE::const_ref vault, SLE::const_ref broker);
/**
* Return a LoanBroker's posterior live cover exponent after applying an
* unrounded delta.
*/
[[nodiscard]] int
getPosteriorBrokerCoverScale(SLE::const_ref vault, SLE::const_ref broker, STAmount const& delta);
/**
* Round a cover delta at the LoanBroker's posterior live exponent.
*/
[[nodiscard]] STAmount
roundToPosteriorBrokerCoverScale(
SLE::const_ref vault,
SLE::const_ref broker,
STAmount const& delta,
Number::RoundingMode roundingMode);
/**
* Check whether `amount` is an admissible optional cover inflow.
*
* Legacy and CashBasis Vaults always succeed. A LoanBroker attached to a
* FixedPrecision Vault must remain at the Vault's base scale after applying
* the rounded amount, and its posterior CoverAvailable must stay within the
* Open zone.
*/
[[nodiscard]] TER
checkOptionalBrokerCoverInflow(SLE::const_ref vault, SLE::const_ref broker, STAmount const& amount);
// Lending protocol has dependencies, so capture them here.
bool
checkLendingProtocolDependencies(Rules const& rules, STTx const& tx);
@@ -262,20 +302,16 @@ getAssetsTotalScale(SLE::const_ref vaultSle)
return scale(vaultSle->at(sfAssetsTotal), vaultSle->at(sfAsset));
}
// Compute the minimum required broker cover, rounded consistently.
// DebtTotal is a broker-level aggregate maintained at vault scale, so the
// rounding must also use vault scale — never an individual loan's scale.
inline Number
minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::const_ref vaultSle)
{
XRPL_ASSERT(
vaultSle && vaultSle->getType() == ltVAULT, "xrpl::minimumBrokerCover : valid Vault sle");
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
return roundToAsset(
vaultSle->at(sfAsset),
tenthBipsOfValue(debtTotal, coverRateMinimum),
getAssetsTotalScale(vaultSle));
}
/**
* Minimum required broker cover, rounded up.
*
* DebtTotal is a broker-level aggregate, never rounded at an individual
* loan's scale. Legacy and CashBasis Vaults round at the live AssetsTotal
* exponent. FixedPrecision Vaults round at the Vault's base exponent
* (`-Scale`, or 0 for integral assets).
*/
Number
minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::const_ref vaultSle);
TER
checkLoanGuards(

View File

@@ -35,6 +35,18 @@
namespace xrpl {
namespace {
[[nodiscard]] int
liveScale(Number const& reference, Asset const& asset, int baseScale)
{
if (reference == beast::kZero)
return baseScale;
return std::max(baseScale, scale(reference, asset));
}
} // namespace
[[nodiscard]] TER
canApplyToBrokerCover(
ReadView const& view,
@@ -66,6 +78,126 @@ canApplyToBrokerCover(
return tesSUCCESS;
}
[[nodiscard]] int
getBrokerCoverScale(SLE::const_ref vault, SLE::const_ref broker)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT, "xrpl::getBrokerCoverScale : valid Vault sle");
XRPL_ASSERT(
broker && broker->getType() == ltLOAN_BROKER,
"xrpl::getBrokerCoverScale : valid LoanBroker sle");
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return scale(broker->at(sfCoverAvailable), vault->at(sfAsset));
case VaultVersion::FixedPrecision:
return liveScale(
broker->at(sfCoverAvailable), vault->at(sfAsset), getVaultBaseScale(vault));
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getBrokerCoverScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] int
getPosteriorBrokerCoverScale(SLE::const_ref vault, SLE::const_ref broker, STAmount const& delta)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::getPosteriorBrokerCoverScale : valid Vault sle");
XRPL_ASSERT(
broker && broker->getType() == ltLOAN_BROKER,
"xrpl::getPosteriorBrokerCoverScale : valid LoanBroker sle");
XRPL_ASSERT(
delta.asset() == vault->at(sfAsset),
"xrpl::getPosteriorBrokerCoverScale : delta and Vault asset match");
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::ToNearest);
return broker->at(sfCoverAvailable) + delta;
}();
switch (getVaultVersion(vault))
{
case VaultVersion::Legacy:
case VaultVersion::CashBasis:
return scale(posterior, vault->at(sfAsset));
case VaultVersion::FixedPrecision:
return liveScale(posterior, vault->at(sfAsset), getVaultBaseScale(vault));
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::getPosteriorBrokerCoverScale : valid VaultVersion");
return Number::kMinExponent - 1;
// LCOV_EXCL_STOP
}
[[nodiscard]] STAmount
roundToPosteriorBrokerCoverScale(
SLE::const_ref vault,
SLE::const_ref broker,
STAmount const& delta,
Number::RoundingMode roundingMode)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::roundToPosteriorBrokerCoverScale : valid Vault sle");
XRPL_ASSERT(
broker && broker->getType() == ltLOAN_BROKER,
"xrpl::roundToPosteriorBrokerCoverScale : valid LoanBroker sle");
XRPL_ASSERT(
delta.asset() == vault->at(sfAsset),
"xrpl::roundToPosteriorBrokerCoverScale : delta and Vault asset match");
if (delta.integral())
return delta;
return roundToScale(delta, getPosteriorBrokerCoverScale(vault, broker, delta), roundingMode);
}
[[nodiscard]] TER
checkOptionalBrokerCoverInflow(SLE::const_ref vault, SLE::const_ref broker, STAmount const& amount)
{
XRPL_ASSERT(
vault && vault->getType() == ltVAULT,
"xrpl::checkOptionalBrokerCoverInflow : valid Vault sle");
XRPL_ASSERT(
broker && broker->getType() == ltLOAN_BROKER,
"xrpl::checkOptionalBrokerCoverInflow : valid LoanBroker sle");
XRPL_ASSERT(
amount.asset() == vault->at(sfAsset),
"xrpl::checkOptionalBrokerCoverInflow : amount and Vault asset match");
XRPL_ASSERT(!amount.negative(), "xrpl::checkOptionalBrokerCoverInflow : non-negative amount");
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
return tesSUCCESS;
STAmount const rounded =
roundToPosteriorBrokerCoverScale(vault, broker, amount, Number::RoundingMode::TowardsZero);
int const baseScale = getVaultBaseScale(vault);
if (getPosteriorBrokerCoverScale(vault, broker, rounded) != baseScale)
return tecLIMIT_EXCEEDED;
Number const posterior = [&] {
NumberRoundModeGuard const rg(Number::RoundingMode::TowardsZero);
return broker->at(sfCoverAvailable) + rounded;
}();
if (posterior > getVaultOpenLimit(vault))
return tecLIMIT_EXCEEDED;
return tesSUCCESS;
}
Number
minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::const_ref vaultSle)
{
XRPL_ASSERT(
vaultSle && vaultSle->getType() == ltVAULT, "xrpl::minimumBrokerCover : valid Vault sle");
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
return roundToAsset(
vaultSle->at(sfAsset),
tenthBipsOfValue(debtTotal, coverRateMinimum),
getVaultBaseScale(vaultSle));
}
bool
checkLendingProtocolDependencies(Rules const& rules, STTx const& tx)
{

View File

@@ -8,6 +8,7 @@
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Concepts.h>
@@ -158,7 +159,7 @@ determineAsset(
std::expected<STAmount, TER>
determineClawAmount(
SLE const& sleBroker,
SLE::const_ref sleBroker,
Asset const& vaultAsset,
std::optional<STAmount> const& amount,
SLE::const_ref vaultSle,
@@ -166,20 +167,23 @@ determineClawAmount(
{
auto const maxClawAmount = [&]() {
auto const minRequiredCover = [&]() {
if (rules.enabled(fixCleanup3_2_0))
if (rules.enabled(fixCleanup3_2_0) ||
getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
{
return minimumBrokerCover(
sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]), vaultSle);
sleBroker->at(sfDebtTotal),
TenthBips32(sleBroker->at(sfCoverRateMinimum)),
vaultSle);
}
// Always round the minimum required up
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
return tenthBipsOfValue(
sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]));
sleBroker->at(sfDebtTotal), TenthBips32(sleBroker->at(sfCoverRateMinimum)));
}();
// The subtraction probably won't round, but round down if it does.
NumberRoundModeGuard const mg(Number::RoundingMode::Downward);
return sleBroker[sfCoverAvailable] - minRequiredCover;
return sleBroker->at(sfCoverAvailable) - minRequiredCover;
}();
if (maxClawAmount <= beast::kZero)
return std::unexpected(tecINSUFFICIENT_FUNDS);
@@ -187,12 +191,24 @@ determineClawAmount(
// Use the vaultAsset here, because it will be the right type in all
// circumstances. The amount may be an IOU indicating the pseudo-account's
// asset, which is correct, but not what is needed here.
if (!amount || *amount == beast::kZero)
return STAmount{vaultAsset, maxClawAmount};
Number const magnitude{*amount};
if (magnitude > maxClawAmount)
return STAmount{vaultAsset, maxClawAmount};
return STAmount{vaultAsset, magnitude};
STAmount const requested = [&] {
if (!amount || *amount == beast::kZero)
return STAmount{vaultAsset, maxClawAmount};
Number const magnitude{*amount};
if (magnitude > maxClawAmount)
return STAmount{vaultAsset, maxClawAmount};
return STAmount{vaultAsset, magnitude};
}();
if (getVaultVersion(vaultSle) != VaultVersion::FixedPrecision)
return requested;
// Negate so the posterior is CoverAvailable minus amount.
STAmount const rounded = -roundToPosteriorBrokerCoverScale(
vaultSle, sleBroker, -requested, Number::RoundingMode::TowardsZero);
if (rounded == beast::kZero)
return std::unexpected(tecPRECISION_LOSS);
return rounded;
}
template <ValidIssueType T>
@@ -294,7 +310,7 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
}
auto const findClawAmount =
determineClawAmount(*sleBroker, vaultAsset, amount, vault, ctx.view.rules());
determineClawAmount(sleBroker, vaultAsset, amount, vault, ctx.view.rules());
if (!findClawAmount)
{
JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum.";
@@ -302,9 +318,15 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
}
STAmount const& clawAmount = *findClawAmount;
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, clawAmount, ctx.j, "LoanBrokerCoverClawback"))
return ret;
// FixedPrecision outflows already rounded at the posterior exponent; the
// live CoverAvailable scale used by canApplyToBrokerCover would reject a
// re-fining clawback as sub-ULP.
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
{
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, clawAmount, ctx.j, "LoanBrokerCoverClawback"))
return ret;
}
// Explicitly check the balance of the trust line / MPT to make sure the
// balance is actually there. It should always match `sfCoverAvailable`, so
@@ -357,7 +379,7 @@ LoanBrokerCoverClawback::doApply()
auto const vaultAsset = vault->at(sfAsset);
auto const findClawAmount =
determineClawAmount(*sleBroker, vaultAsset, amount, vault, view().rules());
determineClawAmount(sleBroker, vaultAsset, amount, vault, view().rules());
if (!findClawAmount)
return tecINTERNAL; // LCOV_EXCL_LINE
STAmount const& clawAmount = *findClawAmount;

View File

@@ -5,6 +5,7 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/SField.h>
@@ -104,22 +105,30 @@ LoanBrokerCoverDeposit::preclaim(PreclaimContext const& ctx)
// here in preclaim lets us reject sub-cover-scale dust early with tecPRECISION_LOSS instead of
// failing only in doApply.
auto const roundedAmount = [&]() -> STAmount {
if (getVaultVersion(vault) == VaultVersion::FixedPrecision)
return roundToPosteriorBrokerCoverScale(
vault, sleBroker, amount, Number::RoundingMode::TowardsZero);
if (!fix320Enabled)
return tx[sfAmount];
return amount;
return roundToScale(
tx[sfAmount],
amount,
scale(sleBroker->at(sfCoverAvailable), vaultAsset),
Number::RoundingMode::Downward);
}();
if (fix320Enabled && roundedAmount == beast::kZero)
if ((fix320Enabled || getVaultVersion(vault) == VaultVersion::FixedPrecision) &&
roundedAmount == beast::kZero)
{
JLOG(ctx.j.warn()) << "LoanBrokerCoverDeposit: deposit amount: " << tx[sfAmount]
JLOG(ctx.j.warn()) << "LoanBrokerCoverDeposit: deposit amount: " << amount
<< " is zero at loan broker scale";
return tecPRECISION_LOSS;
}
if (auto const ter = checkOptionalBrokerCoverInflow(vault, sleBroker, roundedAmount);
!isTesSuccess(ter))
return ter;
if (accountHolds(
ctx.view,
account,
@@ -155,6 +164,9 @@ LoanBrokerCoverDeposit::doApply()
// see the rationale comment in preclaim.
bool const fix320Enabled = view().rules().enabled(fixCleanup3_2_0);
auto const amount = [&]() -> STAmount {
if (getVaultVersion(vault) == VaultVersion::FixedPrecision)
return roundToPosteriorBrokerCoverScale(
vault, broker, tx[sfAmount], Number::RoundingMode::TowardsZero);
if (!fix320Enabled)
return tx[sfAmount];

View File

@@ -8,6 +8,7 @@
#include <xrpl/ledger/helpers/CredentialHelpers.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Protocol.h>
@@ -103,10 +104,29 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
if (amount.asset() != vaultAsset)
return tecWRONG_ASSET;
// Helper handles both IOU and MPT correctly without explicit branching.
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, amount, ctx.j, "LoanBrokerCoverWithdraw"))
return ret;
auto const roundedAmount = [&] {
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
return amount;
// Negate so the posterior is CoverAvailable minus amount.
return -roundToPosteriorBrokerCoverScale(
vault, sleBroker, -amount, Number::RoundingMode::TowardsZero);
}();
if (getVaultVersion(vault) == VaultVersion::FixedPrecision && roundedAmount == beast::kZero)
{
JLOG(ctx.j.warn()) << "LoanBrokerCoverWithdraw: withdraw amount: " << amount
<< " is zero at loan broker scale";
return tecPRECISION_LOSS;
}
// FixedPrecision outflows already rounded at the posterior exponent; the
// live CoverAvailable scale used by canApplyToBrokerCover would reject a
// re-fining withdrawal as sub-ULP.
if (getVaultVersion(vault) != VaultVersion::FixedPrecision)
{
if (auto const ret = canApplyToBrokerCover(
ctx.view, sleBroker, vaultAsset, roundedAmount, ctx.j, "LoanBrokerCoverWithdraw"))
return ret;
}
// The broker's pseudo-account is the source of funds.
auto const pseudoAccountID = sleBroker->at(sfAccount);
@@ -170,7 +190,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
// Cover Rate is in 1/10 bips units
auto const currentDebtTotal = sleBroker->at(sfDebtTotal);
auto const minimumCover = [&]() {
if (fix320Enabled)
if (fix320Enabled || getVaultVersion(vault) == VaultVersion::FixedPrecision)
{
return minimumBrokerCover(
currentDebtTotal, TenthBips32{sleBroker->at(sfCoverRateMinimum)}, vault);
@@ -184,9 +204,9 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
tenthBipsOfValue(currentDebtTotal, TenthBips32(sleBroker->at(sfCoverRateMinimum))),
scale(currentDebtTotal, vaultAsset));
}();
if (coverAvail < amount)
if (coverAvail < roundedAmount)
return tecINSUFFICIENT_FUNDS;
if ((coverAvail - amount) < minimumCover)
if ((coverAvail - roundedAmount) < minimumCover)
return tecINSUFFICIENT_FUNDS;
auto const freezeHandling = fix330Enabled && dstAcct == vaultAsset.getIssuer()
@@ -199,7 +219,7 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
vaultAsset,
freezeHandling,
AuthHandling::ZeroIfUnauthorized,
ctx.j) < amount)
ctx.j) < roundedAmount)
return tecINSUFFICIENT_FUNDS;
return tesSUCCESS;
@@ -211,7 +231,7 @@ LoanBrokerCoverWithdraw::doApply()
auto const& tx = ctx_.tx;
auto const brokerID = tx[sfLoanBrokerID];
auto const amount = tx[sfAmount];
auto const requestedAmount = tx[sfAmount];
auto const dstAcct = tx[~sfDestination].value_or(accountID_);
auto broker = view().peek(keylet::loanBroker(brokerID));
@@ -223,6 +243,11 @@ LoanBrokerCoverWithdraw::doApply()
return tecINTERNAL; // LCOV_EXCL_LINE
auto const vaultAsset = vault->at(sfAsset);
auto const amount = getVaultVersion(vault) == VaultVersion::FixedPrecision
// Negate so the posterior is CoverAvailable minus amount.
? -roundToPosteriorBrokerCoverScale(
vault, broker, -requestedAmount, Number::RoundingMode::TowardsZero)
: requestedAmount;
auto const brokerPseudoID = *broker->at(sfAccount);

View File

@@ -174,7 +174,12 @@ LoanBrokerSet::preclaim(PreclaimContext const& ctx)
// type. This is mostly only relevant for integral (non-IOU) types
for (auto const& field : getValueFields())
{
if (auto const value = tx[field]; value && STAmount{asset, *value} != *value)
if (auto const value = tx[field]; value &&
(STAmount{asset, *value} != *value ||
(getVaultVersion(sleVault) == VaultVersion::FixedPrecision &&
roundToAsset(
asset, *value, getVaultBaseScale(sleVault), Number::RoundingMode::TowardsZero) !=
*value)))
{
JLOG(ctx.j.warn()) << field.f->getName() << " (" << *value
<< ") can not be represented as a(n) " << to_string(asset) << ".";

View File

@@ -9,6 +9,7 @@
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
@@ -375,7 +376,8 @@ LoanPay::doApply()
// DebtTotal) use vaultScale. The legacy path below intentionally retains
// its pre-amendment loanScale behavior.
auto const minCover = [&]() {
if (view.rules().enabled(fixCleanup3_2_0))
if (view.rules().enabled(fixCleanup3_2_0) ||
getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
{
return minimumBrokerCover(debtTotalProxy.value(), coverRateMinimum, vaultSle);
}

View File

@@ -559,7 +559,8 @@ LoanSet::doApply()
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
{
auto const minCover = [&]() {
if (ctx_.view().rules().enabled(fixCleanup3_2_0))
if (ctx_.view().rules().enabled(fixCleanup3_2_0) ||
getVaultVersion(vaultSle) == VaultVersion::FixedPrecision)
{
return minimumBrokerCover(newDebtTotal, coverRateMinimum, vaultSle);
}

View File

@@ -20,9 +20,11 @@
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTakesAsset.h>
#include <xrpl/protocol/SeqProxy.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/UintTypes.h>
#include <xrpl/protocol/Units.h>
#include <cstdint>
@@ -1862,6 +1864,17 @@ public:
.amount = STAmount{iou, Number{1, -13}},
.expected = tesSUCCESS,
},
{
// CoverAvailable 1e10 is exponent -5. 1e-6 is non-zero at
// FixedPrecision P=6, but ToNearest at the live cover
// exponent rounds it to zero. Withdraw/clawback skip this
// helper for FixedPrecision so a re-fining outflow can
// succeed.
.name = "Coarsened live scale rejects re-fining amount",
.coverAvailable = Number{1, 10},
.amount = STAmount{iou, Number{1, -6}},
.expected = tecPRECISION_LOSS,
},
};
Env const env{*this};
@@ -1891,6 +1904,26 @@ public:
envOff.journal,
"test") == tesSUCCESS);
}
testcase("minimumBrokerCover: FixedPrecision uses base scale");
auto const makeVault = [&](VaultVersion version) {
auto vault = std::make_shared<SLE>(ltVAULT, uint256{2u});
vault->setFieldIssue(sfAsset, STIssue{sfAsset, iou});
vault->at(sfAssetsTotal) = Number{1};
vault->at(sfScale) = 6;
vault->at(sfLEVersion) = std::to_underlying(version);
associateAsset(*vault, iou);
return vault;
};
Number const debtTotal{15, -2};
TenthBips32 const coverRate{1};
BEAST_EXPECT(
(minimumBrokerCover(debtTotal, coverRate, makeVault(VaultVersion::FixedPrecision)) ==
Number{2, -6}));
BEAST_EXPECT(
(minimumBrokerCover(debtTotal, coverRate, makeVault(VaultVersion::CashBasis)) ==
Number{15, -7}));
}
// Targeted unit test for getLoanDefaultFreezeExemptAccounts(): builds a real

View File

@@ -73,12 +73,12 @@ class LoanBroker_test : public beast::unit_test::Suite
// Ensure that all the features needed for Lending Protocol are included,
// even if they are set to unsupported.
//
// featureLendingProtocolV1_1 is excluded from the default set: it adds
// the closed-ended vault gate on LoanBrokerSet::preclaim (see
// LoanBrokerSet.cpp), but this suite exercises loan-broker mechanics on
// plain open-ended vaults. Tests that specifically exercise the
// amendment opt it back in explicitly and use closed-ended vaults.
FeatureBitset const all_{jtx::testableAmendments() - featureLendingProtocolV1_1};
// V1.1 and V1.2 are excluded from the default set: they add the
// closed-ended Vault gate and fixed-precision behavior, while this suite
// primarily exercises legacy LoanBroker mechanics on open-ended Vaults.
// Tests for the new behavior opt both amendments back in explicitly.
FeatureBitset const all_{
jtx::testableAmendments() - featureLendingProtocolV1_1 - featureLendingProtocolV1_2};
void
testDisabled()
@@ -2757,6 +2757,135 @@ class LoanBroker_test : public beast::unit_test::Suite
BEAST_EXPECT(!env.le(credKeylet));
}
void
testFixedPrecisionCover()
{
using namespace jtx;
using namespace loan_broker;
testcase("FixedPrecision LoanBroker cover");
FeatureBitset const v12{all_ | featureLendingProtocolV1_1 | featureLendingProtocolV1_2};
Account const issuer{"issuer"};
Account const alice{"alice"};
Account const borrower{"borrower"};
Env env{*this, v12};
env.fund(XRP(100'000), issuer, alice, borrower);
env.close();
env(fset(issuer, asfAllowTrustLineClawback));
env.close();
PrettyAsset const iou = issuer["IOU"];
env(trust(alice, iou(Number{10, 10})));
env(trust(borrower, iou(Number{10, 10})));
env(pay(issuer, alice, iou(Number{10, 9})));
env(pay(issuer, borrower, iou(Number{10, 2})));
Vault const vault{env};
[[maybe_unused]] auto [createTx, vaultKeylet, subscriptionDate] =
vault.createClosedEnded({.owner = alice, .asset = iou});
createTx[sfScale] = 6;
env(createTx);
env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = iou(100)}));
vault.closePastSubscription(subscriptionDate);
env(set(alice, vaultKeylet.key), kDebtMaximum(Number{15, -7}), Ter(tecPRECISION_LOSS));
auto const brokerKeylet =
keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
env(set(alice, vaultKeylet.key), kDebtMaximum(Number{1, -6}));
env(coverDeposit(alice, brokerKeylet.key, iou(Number{1, -7})), Ter(tecPRECISION_LOSS));
Number const openLimit{9, 9};
env(coverDeposit(alice, brokerKeylet.key, iou(Number{18, -7})));
{
auto const broker = env.le(brokerKeylet);
BEAST_EXPECT(broker);
if (broker)
BEAST_EXPECT((broker->at(sfCoverAvailable) == Number{1, -6}));
}
env(coverDeposit(alice, brokerKeylet.key, iou(openLimit - Number{1, -6})));
env(coverDeposit(alice, brokerKeylet.key, iou(Number{1, -6})), Ter(tecLIMIT_EXCEEDED));
auto const coverAvailable = [&]() {
auto const broker = env.le(brokerKeylet);
BEAST_EXPECT(broker);
return broker ? broker->at(sfCoverAvailable) : Number{0};
};
BEAST_EXPECT(coverAvailable() == openLimit);
env(coverWithdraw(alice, brokerKeylet.key, iou(Number{18, -7})));
BEAST_EXPECT((coverAvailable() == openLimit - Number{1, -6}));
env(coverClawback(issuer), kLoanBrokerId(brokerKeylet.key), kAmount(iou(Number{18, -7})));
BEAST_EXPECT((coverAvailable() == openLimit - Number{2, -6}));
env(coverWithdraw(alice, brokerKeylet.key, iou(Number{1, -7})), Ter(tecPRECISION_LOSS));
env(coverClawback(issuer),
kLoanBrokerId(brokerKeylet.key),
kAmount(iou(Number{1, -7})),
Ter(tecPRECISION_LOSS));
env(coverClawback(issuer), kLoanBrokerId(brokerKeylet.key));
BEAST_EXPECT((coverAvailable() == Number{0}));
auto const minCoverBroker =
keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
env(set(alice, vaultKeylet.key),
kDebtMaximum(Number{100}),
kCoverRateMinimum(percentageToTenthBips(10)),
kCoverRateLiquidation(percentageToTenthBips(25)));
env(coverDeposit(alice, minCoverBroker.key, iou(Number{1, -1})));
env(loan::set(borrower, minCoverBroker.key, Number{1}),
Sig(sfCounterpartySignature, alice),
Fee(env.current()->fees().base * 2));
{
auto const broker = env.le(minCoverBroker);
BEAST_EXPECT(broker);
if (broker)
BEAST_EXPECT((broker->at(sfDebtTotal) == Number{1}));
}
env(coverWithdraw(alice, minCoverBroker.key, iou(Number{1, -6})),
Ter(tecINSUFFICIENT_FUNDS));
env(loan::set(borrower, minCoverBroker.key, Number{1}),
Sig(sfCounterpartySignature, alice),
Fee(env.current()->fees().base * 2),
Ter(tecINSUFFICIENT_FUNDS));
auto const loanKeylet = keylet::loan(minCoverBroker.key, SeqProxy::rawSequence(1));
env(loan::pay(borrower, loanKeylet.key, iou(1).value()));
{
testcase("FixedPrecision LoanBroker cover: XRP");
[[maybe_unused]] auto [xrpTx, xrpVault, xrpSub] =
vault.createClosedEnded({.owner = alice, .asset = xrpIssue()});
env(xrpTx);
auto const xrpBroker =
keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
env(set(alice, xrpVault.key));
env(coverDeposit(alice, xrpBroker.key, XRP(10)));
env(coverWithdraw(alice, xrpBroker.key, XRP(1)));
}
{
testcase("FixedPrecision LoanBroker cover: MPT");
MPTTester mptt{env, issuer, kMptInitNoFund};
mptt.create({.flags = tfMPTCanClawback | tfMPTCanTransfer | tfMPTCanLock});
PrettyAsset const mpt = mptt["MPT"];
mptt.authorize({.account = alice});
env(pay(issuer, alice, mpt(100)));
[[maybe_unused]] auto [mptTx, mptVault, mptSub] =
vault.createClosedEnded({.owner = alice, .asset = mpt});
env(mptTx);
auto const mptBroker =
keylet::loanBroker(alice.id(), SeqProxy::rawSequence(env.seq(alice)));
env(set(alice, mptVault.key));
env(coverDeposit(alice, mptBroker.key, mpt(10).value()));
env(coverWithdraw(alice, mptBroker.key, mpt(1).value()));
env(coverClawback(issuer), kLoanBrokerId(mptBroker.key), kAmount(mpt(1)));
}
}
// Exercises canApplyToBrokerCover (fixCleanup3_2_0): a deposit, withdraw,
// or clawback whose amount rounds to zero at sfCoverAvailable's precision
// scale must be rejected with tecPRECISION_LOSS once the amendment is on,
@@ -2979,6 +3108,7 @@ public:
testCoverWithdrawFreezes();
testCoverWithdrawSelfWhileFrozen();
testFixedPrecisionCover();
testCoverPrecisionGuard();
testLoanBrokerSetDebtMaximum();

View File

@@ -1,10 +1,13 @@
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/ledger/helpers/LendingHelpers.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
@@ -44,6 +47,15 @@ makeVault(
return vault;
}
std::shared_ptr<SLE>
makeBroker(Asset const& asset, Number const& coverAvailable)
{
auto broker = std::make_shared<SLE>(ltLOAN_BROKER, uint256{2u});
broker->at(sfCoverAvailable) = coverAvailable;
associateAsset(*broker, asset);
return broker;
}
TEST(VaultGrid, BaseAndLiveScale)
{
test::Account const issuer{"issuer"};
@@ -207,5 +219,65 @@ TEST(VaultGrid, OptionalInflowIncludesYieldUnrealized)
EXPECT_EQ(checkOptionalVaultInflow(vault, amount), tecLIMIT_EXCEEDED);
}
TEST(VaultGrid, BrokerCoverScaleAndRounding)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto const vault = makeVault(iou, Number{0}, VaultVersion::FixedPrecision, 6);
auto broker = makeBroker(iou, Number{9'999'999'999'999'999, -6});
STAmount const inflow{iou, Number{21, -6}};
EXPECT_EQ(getBrokerCoverScale(vault, broker), -6);
EXPECT_EQ(getPosteriorBrokerCoverScale(vault, broker, inflow), -5);
EXPECT_EQ(
roundToPosteriorBrokerCoverScale(vault, broker, inflow, Number::RoundingMode::TowardsZero),
STAmount(iou, Number{20, -6}));
broker->at(sfCoverAvailable) = Number{1'000'000'000'000'001, -5};
associateAsset(*broker, iou);
STAmount const outflow{iou, -Number{11, -6}};
EXPECT_EQ(getBrokerCoverScale(vault, broker), -5);
EXPECT_EQ(getPosteriorBrokerCoverScale(vault, broker, outflow), -6);
EXPECT_EQ(
roundToPosteriorBrokerCoverScale(vault, broker, outflow, Number::RoundingMode::TowardsZero),
outflow);
// CoverAvailable exactly 1e10 (exponent -5). Withdrawing 1e-6 re-fines
// to -6; the posterior rounded amount is 1e-6, which isZeroAtScale(-5)
// would treat as zero.
broker->at(sfCoverAvailable) = Number{1, 10};
associateAsset(*broker, iou);
STAmount const refine{iou, -Number{1, -6}};
EXPECT_EQ(getBrokerCoverScale(vault, broker), -5);
EXPECT_EQ(getPosteriorBrokerCoverScale(vault, broker, refine), -6);
EXPECT_EQ(
roundToPosteriorBrokerCoverScale(vault, broker, refine, Number::RoundingMode::TowardsZero),
refine);
}
TEST(VaultGrid, BrokerCoverOptionalInflowBoundaries)
{
test::Account const issuer{"issuer"};
Issue const iou{toCurrency("USD"), issuer.id()};
auto fixedIou = makeVault(iou, Number{0}, VaultVersion::FixedPrecision, 10);
auto iouBroker = makeBroker(iou, Number{9, 5});
STAmount const iouUnit{iou, Number{1, -10}};
EXPECT_EQ(checkOptionalBrokerCoverInflow(fixedIou, iouBroker, STAmount{iou}), tesSUCCESS);
EXPECT_EQ(checkOptionalBrokerCoverInflow(fixedIou, iouBroker, iouUnit), tecLIMIT_EXCEEDED);
auto legacy = makeVault(iou, Number{0}, VaultVersion::CashBasis, 10);
EXPECT_EQ(checkOptionalBrokerCoverInflow(legacy, iouBroker, iouUnit), tesSUCCESS);
for (Asset const asset : {Asset{xrpIssue()}, Asset{MPTIssue{makeMptID(1, issuer.id())}}})
{
auto vault = makeVault(asset, Number{0}, VaultVersion::FixedPrecision, 0);
auto broker = makeBroker(asset, Number{9, 15});
EXPECT_EQ(
checkOptionalBrokerCoverInflow(vault, broker, STAmount{asset, std::uint64_t{1}}),
tecLIMIT_EXCEEDED);
}
}
} // namespace
} // namespace xrpl