mirror of
https://github.com/XRPLF/rippled.git
synced 2026-07-24 15:40:26 +00:00
fix: Use consistent scale for debtTotal (#7093)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -203,6 +203,21 @@ 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));
|
||||
}
|
||||
|
||||
TER
|
||||
checkLoanGuards(
|
||||
Asset const& vaultAsset,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <xrpl/protocol/AccountID.h>
|
||||
#include <xrpl/protocol/Asset.h>
|
||||
#include <xrpl/protocol/Concepts.h>
|
||||
#include <xrpl/protocol/Feature.h>
|
||||
#include <xrpl/protocol/Indexes.h>
|
||||
#include <xrpl/protocol/Issue.h>
|
||||
#include <xrpl/protocol/LedgerFormats.h>
|
||||
@@ -160,15 +161,25 @@ Expected<STAmount, TER>
|
||||
determineClawAmount(
|
||||
SLE const& sleBroker,
|
||||
Asset const& vaultAsset,
|
||||
std::optional<STAmount> const& amount)
|
||||
std::optional<STAmount> const& amount,
|
||||
SLE::const_ref vaultSle,
|
||||
Rules const& rules)
|
||||
{
|
||||
auto const maxClawAmount = [&]() {
|
||||
// Always round the minimum required up
|
||||
NumberRoundModeGuard const mg1(Number::RoundingMode::Upward);
|
||||
auto const minRequiredCover =
|
||||
tenthBipsOfValue(sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]));
|
||||
auto const minRequiredCover = [&]() {
|
||||
if (rules.enabled(fixCleanup3_2_0))
|
||||
{
|
||||
return minimumBrokerCover(
|
||||
sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]), vaultSle);
|
||||
}
|
||||
|
||||
// Always round the minimum required up
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
return tenthBipsOfValue(
|
||||
sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]));
|
||||
}();
|
||||
// The subtraction probably won't round, but round down if it does.
|
||||
NumberRoundModeGuard const mg2(Number::RoundingMode::Downward);
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Downward);
|
||||
return sleBroker[sfCoverAvailable] - minRequiredCover;
|
||||
}();
|
||||
if (maxClawAmount <= beast::kZero)
|
||||
@@ -283,7 +294,8 @@ LoanBrokerCoverClawback::preclaim(PreclaimContext const& ctx)
|
||||
}
|
||||
}
|
||||
|
||||
auto const findClawAmount = determineClawAmount(*sleBroker, vaultAsset, amount);
|
||||
auto const findClawAmount =
|
||||
determineClawAmount(*sleBroker, vaultAsset, amount, vault, ctx.view.rules());
|
||||
if (!findClawAmount)
|
||||
{
|
||||
JLOG(ctx.j.warn()) << "LoanBroker cover is already at minimum.";
|
||||
@@ -345,7 +357,8 @@ LoanBrokerCoverClawback::doApply()
|
||||
|
||||
auto const vaultAsset = vault->at(sfAsset);
|
||||
|
||||
auto const findClawAmount = determineClawAmount(*sleBroker, vaultAsset, amount);
|
||||
auto const findClawAmount =
|
||||
determineClawAmount(*sleBroker, vaultAsset, amount, vault, view().rules());
|
||||
if (!findClawAmount)
|
||||
return tecINTERNAL; // LCOV_EXCL_LINE
|
||||
STAmount const& clawAmount = *findClawAmount;
|
||||
|
||||
@@ -142,6 +142,12 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
|
||||
// Cover Rate is in 1/10 bips units
|
||||
auto const currentDebtTotal = sleBroker->at(sfDebtTotal);
|
||||
auto const minimumCover = [&]() {
|
||||
if (ctx.view.rules().enabled(fixCleanup3_2_0))
|
||||
{
|
||||
return minimumBrokerCover(
|
||||
currentDebtTotal, TenthBips32{sleBroker->at(sfCoverRateMinimum)}, vault);
|
||||
}
|
||||
|
||||
// Always round the minimum required up.
|
||||
// Applies to `tenthBipsOfValue` as well as `roundToAsset`.
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
|
||||
@@ -311,6 +311,8 @@ LoanPay::doApply()
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
auto debtTotalProxy = brokerSle->at(sfDebtTotal);
|
||||
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
|
||||
// Send the broker fee to the owner if they have sufficient cover available,
|
||||
// _and_ if the owner can receive funds
|
||||
// _and_ if the broker is authorized to hold funds. If not, so as not to
|
||||
@@ -320,14 +322,22 @@ LoanPay::doApply()
|
||||
// Normally freeze status is checked in preclaim, but we do it here to
|
||||
// avoid duplicating the check. It'll claim a fee either way.
|
||||
bool const sendBrokerFeeToOwner = [&]() {
|
||||
// Round the minimum required cover up to be conservative. This ensures
|
||||
// CoverAvailable never drops below the theoretical minimum, protecting
|
||||
// the broker's solvency.
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
return coverAvailableProxy >=
|
||||
roundToAsset(
|
||||
asset, tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), loanScale) &&
|
||||
!isDeepFrozen(view, brokerOwner, asset) &&
|
||||
// In the fixCleanup3_2_0 path, vault-related values (for example,
|
||||
// DebtTotal) use vaultScale. The legacy path below intentionally retains
|
||||
// its pre-amendment loanScale behavior.
|
||||
auto const minCover = [&]() {
|
||||
if (view.rules().enabled(fixCleanup3_2_0))
|
||||
{
|
||||
return minimumBrokerCover(debtTotalProxy.value(), coverRateMinimum, vaultSle);
|
||||
}
|
||||
// Round the minimum required cover up to be conservative. This ensures
|
||||
// CoverAvailable never drops below the theoretical minimum, protecting
|
||||
// the broker's solvency.
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
return roundToAsset(
|
||||
asset, tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum), loanScale);
|
||||
}();
|
||||
return coverAvailableProxy >= minCover && !isDeepFrozen(view, brokerOwner, asset) &&
|
||||
!requireAuth(view, asset, brokerOwner, AuthType::StrongAuth);
|
||||
}();
|
||||
|
||||
@@ -423,10 +433,6 @@ LoanPay::doApply()
|
||||
auto assetsAvailableProxy = vaultSle->at(sfAssetsAvailable);
|
||||
auto assetsTotalProxy = vaultSle->at(sfAssetsTotal);
|
||||
|
||||
// The vault may be at a different scale than the loan. Reduce rounding
|
||||
// errors during the payment by rounding some of the values to that scale.
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
|
||||
auto const totalPaidToVaultRaw = paymentParts->principalPaid + paymentParts->interestPaid;
|
||||
auto const totalPaidToVaultRounded =
|
||||
roundToAsset(asset, totalPaidToVaultRaw, vaultScale, Number::RoundingMode::Downward);
|
||||
|
||||
@@ -493,11 +493,19 @@ LoanSet::doApply()
|
||||
}
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
{
|
||||
// Round the minimum required cover up to be conservative. This ensures
|
||||
// CoverAvailable never drops below the theoretical minimum, protecting
|
||||
// the broker's solvency.
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
if (brokerSle->at(sfCoverAvailable) < tenthBipsOfValue(newDebtTotal, coverRateMinimum))
|
||||
auto const minCover = [&]() {
|
||||
if (ctx_.view().rules().enabled(fixCleanup3_2_0))
|
||||
{
|
||||
return minimumBrokerCover(newDebtTotal, coverRateMinimum, vaultSle);
|
||||
}
|
||||
|
||||
// Round the minimum required cover up to be conservative. This ensures
|
||||
// CoverAvailable never drops below the theoretical minimum, protecting
|
||||
// the broker's solvency.
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
return tenthBipsOfValue(newDebtTotal, coverRateMinimum);
|
||||
}();
|
||||
if (brokerSle->at(sfCoverAvailable) < minCover)
|
||||
{
|
||||
JLOG(j_.warn()) << "Insufficient first-loss capital to cover the loan.";
|
||||
return tecINSUFFICIENT_FUNDS;
|
||||
|
||||
@@ -7858,6 +7858,414 @@ protected:
|
||||
to_string(tolerance));
|
||||
}
|
||||
|
||||
// Verify that LoanPay, LoanBrokerCoverWithdraw, and LoanSet all use the
|
||||
// same vault-scale minimum cover when fixCleanup3_2_0 is enabled.
|
||||
// Before the amendment, each transactor computed its minimum cover at a
|
||||
// different precision (loanScale, debtScale, or the raw unrounded
|
||||
// tenthBipsOfValue), which could lead to inconsistent decisions for the
|
||||
// same broker state. After the amendment all three use
|
||||
// minimumBrokerCover at vaultScale.
|
||||
void
|
||||
testMinimumBrokerCoverConsistency(FeatureBitset features)
|
||||
{
|
||||
using namespace jtx;
|
||||
using namespace loan;
|
||||
using namespace loanBroker;
|
||||
|
||||
bool const withAmendment = features[fixCleanup3_2_0];
|
||||
|
||||
struct Ctx
|
||||
{
|
||||
jtx::Account issuer;
|
||||
jtx::Account lender;
|
||||
jtx::Account borrower;
|
||||
jtx::PrettyAsset iou;
|
||||
BrokerInfo broker;
|
||||
BrokerParameters brokerParams;
|
||||
};
|
||||
|
||||
// Shared setup, parametrized by vaultDeposit (the only varying setup
|
||||
// field across the three scenarios). Each call runs in its own Env
|
||||
// so multiple invocations within one scenario cannot interfere.
|
||||
// The caller is responsible for invoking testcase(...) before the
|
||||
// first runTest call of each scenario.
|
||||
auto runTest = [&](Number vaultDeposit, auto&& body) {
|
||||
Env env(*this, features);
|
||||
|
||||
Account const issuer{"issuer"};
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
|
||||
env.fund(XRP(1'000'000'000), issuer, lender, borrower);
|
||||
env.close();
|
||||
|
||||
// Enable clawback on the issuer *before* any trust lines exist
|
||||
// (asfAllowTrustLineClawback requires an empty owner directory).
|
||||
env(fset(issuer, asfAllowTrustLineClawback));
|
||||
env.close();
|
||||
|
||||
PrettyAsset const iou = issuer[iouCurrency_];
|
||||
env(trust(lender, iou(1'000'000'000)));
|
||||
env(trust(borrower, iou(1'000'000'000)));
|
||||
env.close();
|
||||
env(pay(issuer, lender, iou(100'000'000)));
|
||||
env(pay(issuer, borrower, iou(100'000'000)));
|
||||
env.close();
|
||||
|
||||
// 13.37% — non-round rate produces a messier minimum.
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = vaultDeposit,
|
||||
.debtMax = 0,
|
||||
.coverRateMin = TenthBips32{13'370},
|
||||
.coverDeposit = 5'000,
|
||||
.managementFeeRate = TenthBips16{500}};
|
||||
|
||||
BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams);
|
||||
|
||||
body(
|
||||
env,
|
||||
Ctx{.issuer = issuer,
|
||||
.lender = lender,
|
||||
.borrower = borrower,
|
||||
.iou = iou,
|
||||
.broker = broker,
|
||||
.brokerParams = brokerParams});
|
||||
};
|
||||
|
||||
// Scenario 1 — LoanPay
|
||||
//
|
||||
// Verify that LoanPay's minimum cover check uses vault scale (not
|
||||
// loan scale). Before the amendment, different loans could produce
|
||||
// different fee routing decisions for the same broker-level state.
|
||||
// Small vault deposit => vaultScale = -12.
|
||||
testcase("LoanPay minimum cover scale consistency");
|
||||
{
|
||||
struct LoanKeylets
|
||||
{
|
||||
Keylet tiny;
|
||||
Keylet big;
|
||||
};
|
||||
|
||||
// Create the tiny + big loans and reduce cover via clawback so
|
||||
// that subsequent LoanPay calls hit the minimum-cover boundary.
|
||||
// Used by the two pay-and-check sub-tests below so each can run
|
||||
// in its own Env.
|
||||
auto setupLoansAndClawback = [&](Env& env, Ctx const& c) -> std::optional<LoanKeylets> {
|
||||
Asset const asset{c.iou};
|
||||
|
||||
// Create the TINY loan first (while vaultScale is still
|
||||
// small). principal 0.01, 0% interest, 1 payment =>
|
||||
// loanScale = vaultScale.
|
||||
auto const brokerSle1 = env.le(keylet::loanbroker(c.broker.brokerID));
|
||||
if (!BEAST_EXPECT(brokerSle1))
|
||||
return std::nullopt;
|
||||
auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence);
|
||||
auto const tinyLoanKeylet = keylet::loan(c.broker.brokerID, tinyLoanSeq);
|
||||
|
||||
env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
|
||||
Sig(sfCounterpartySignature, c.lender),
|
||||
kInterestRate(TenthBips32{0}),
|
||||
kPaymentTotal(1),
|
||||
kPaymentInterval(86400 * 365),
|
||||
Fee(XRP(10)));
|
||||
env.close();
|
||||
|
||||
// Create the BIG loan second. 100% annual interest over 20
|
||||
// payments pushes totalValueOutstanding high enough that
|
||||
// loanScale > vaultScale.
|
||||
auto const brokerSle2 = env.le(keylet::loanbroker(c.broker.brokerID));
|
||||
if (!BEAST_EXPECT(brokerSle2))
|
||||
return std::nullopt;
|
||||
auto const bigLoanSeq = brokerSle2->at(sfLoanSequence);
|
||||
auto const bigLoanKeylet = keylet::loan(c.broker.brokerID, bigLoanSeq);
|
||||
|
||||
env(set(c.borrower, c.broker.brokerID, Number{500}),
|
||||
Sig(sfCounterpartySignature, c.lender),
|
||||
kInterestRate(TenthBips32{100'000}),
|
||||
kPaymentTotal(20),
|
||||
kPaymentInterval(86400 * 365),
|
||||
Fee(XRP(10)));
|
||||
env.close();
|
||||
|
||||
// The tiny loan's scale is frozen at the vault's pre-big-loan
|
||||
// scale, so it is strictly smaller than the big loan's.
|
||||
// After the big loan is created the vault absorbs its value,
|
||||
// pushing vaultScale up to match bigLoanScale.
|
||||
auto const tinyLoanSle = env.le(tinyLoanKeylet);
|
||||
auto const bigLoanSle = env.le(bigLoanKeylet);
|
||||
auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
|
||||
if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle) ||
|
||||
!BEAST_EXPECT(vaultSle))
|
||||
return std::nullopt;
|
||||
if (!BEAST_EXPECT(tinyLoanSle->at(sfLoanScale) == -12) ||
|
||||
!BEAST_EXPECT(bigLoanSle->at(sfLoanScale) == -11) ||
|
||||
!BEAST_EXPECT(getAssetsTotalScale(vaultSle) == -11))
|
||||
return std::nullopt;
|
||||
|
||||
// Use issuer clawback to reduce cover to the minimum the
|
||||
// clawback transactor allows. Compute the amount as
|
||||
// initialCover - expectedCoverAfter so we exercise the exact
|
||||
// clawback rather than relying on the transactor to clip
|
||||
// down.
|
||||
//
|
||||
// Before the amendment the clawback minimum is the
|
||||
// *unrounded* tenthBipsOfValue — strictly less than the
|
||||
// rounded-at-vaultScale minimum LoanPay uses for the big
|
||||
// loan. After the amendment both clawback and LoanPay use
|
||||
// the same rounded minimum (via minimumBrokerCover), so
|
||||
// cover lands exactly at that threshold.
|
||||
Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
|
||||
: Number{1330651855688458000, -15};
|
||||
Number const clawbackAmount =
|
||||
Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
|
||||
|
||||
env(coverClawback(c.issuer),
|
||||
kLoanBrokerId(c.broker.brokerID),
|
||||
kAmount(STAmount{asset, clawbackAmount}));
|
||||
env.close();
|
||||
|
||||
auto const brokerSle = env.le(keylet::loanbroker(c.broker.brokerID));
|
||||
if (!BEAST_EXPECT(brokerSle) ||
|
||||
!BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedCoverAfter))
|
||||
return std::nullopt;
|
||||
|
||||
return LoanKeylets{.tiny = tinyLoanKeylet, .big = bigLoanKeylet};
|
||||
};
|
||||
|
||||
// Pay one loan and report whether the fee went to the broker's
|
||||
// pseudo account (the fallback when cover < minimum) rather
|
||||
// than to the owner.
|
||||
auto feeGoesToPseudo = [&](Env& env, Ctx const& c, Keylet const& loanKeylet) -> bool {
|
||||
Asset const asset{c.iou};
|
||||
auto const brokerSle = env.le(keylet::loanbroker(c.broker.brokerID));
|
||||
if (!BEAST_EXPECT(brokerSle))
|
||||
return false;
|
||||
auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount));
|
||||
auto const pseudoBefore = env.balance(pseudoAcct, c.iou);
|
||||
|
||||
auto const payLoan = env.le(loanKeylet);
|
||||
if (!BEAST_EXPECT(payLoan))
|
||||
return false;
|
||||
auto const periodicPayment = payLoan->at(sfPeriodicPayment);
|
||||
auto const serviceFee = payLoan->at(sfLoanServiceFee);
|
||||
std::int32_t const loanScale = payLoan->at(sfLoanScale);
|
||||
|
||||
auto const payment = roundPeriodicPayment(asset, periodicPayment, loanScale);
|
||||
auto const payAmt = STAmount{asset, payment + serviceFee};
|
||||
|
||||
env(loan::pay(c.borrower, loanKeylet.key, payAmt), Fee(XRP(10)));
|
||||
env.close();
|
||||
|
||||
auto const pseudoAfter = env.balance(pseudoAcct, c.iou);
|
||||
return pseudoAfter.number() > pseudoBefore.number();
|
||||
};
|
||||
|
||||
// Pay the BIG loan in its own Env so its outcome cannot affect
|
||||
// the TINY-loan check. With the fix, LoanPay and clawback use
|
||||
// the same vaultScale minimum (cover == minAtVaultScale =>
|
||||
// fee to owner). Without the fix, LoanPay uses bigLoanScale=-11,
|
||||
// rounds up to a larger minimum than what clawback used =>
|
||||
// cover < min => fee to pseudo.
|
||||
runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
|
||||
auto const loans = setupLoansAndClawback(env, c);
|
||||
if (!loans)
|
||||
return;
|
||||
BEAST_EXPECT(feeGoesToPseudo(env, c, loans->big) == !withAmendment);
|
||||
});
|
||||
|
||||
// Pay the TINY loan in its own Env. Fee goes to the owner
|
||||
// either way:
|
||||
// - With the fix: LoanPay uses vaultScale=-11 (same as
|
||||
// clawback) => owner.
|
||||
// - Without the fix: LoanPay uses tinyLoanScale=-12, rounds
|
||||
// up at -12 (a no-op) => min == cover => owner.
|
||||
runTest(/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
|
||||
auto const loans = setupLoansAndClawback(env, c);
|
||||
if (!loans)
|
||||
return;
|
||||
BEAST_EXPECT(!feeGoesToPseudo(env, c, loans->tiny));
|
||||
});
|
||||
}
|
||||
|
||||
// Scenario 2 — LoanBrokerCoverWithdraw
|
||||
//
|
||||
// Verify that CoverWithdraw's minimum cover check uses vault scale
|
||||
// (not scale(debtTotal, asset)). Before the amendment, CoverWithdraw
|
||||
// used:
|
||||
// roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset))
|
||||
// which could disagree with LoanPay's minimum (which used loanScale).
|
||||
//
|
||||
// Use a large vault deposit so that vaultScale (from AssetsTotal) is
|
||||
// strictly larger than debtScale (from DebtTotal). With
|
||||
// vaultDeposit = 100,000: after the big loan
|
||||
// AssetsTotal ≈ 109,500 → vaultScale = -10
|
||||
// DebtTotal ≈ 10,000 → debtScale = -11
|
||||
// The one-order-of-magnitude gap makes roundToAsset at -10 truncate
|
||||
// more aggressively than at -11, exposing the bug.
|
||||
testcase("CoverWithdraw minimum cover scale consistency");
|
||||
runTest(
|
||||
/*vaultDeposit=*/100'000, [&](Env& env, Ctx const& c) {
|
||||
Asset const asset{c.iou};
|
||||
|
||||
// Create only the big loan to push DebtTotal up to ~10,000
|
||||
// while AssetsTotal stays around 109,500 (dominated by the
|
||||
// large vault deposit).
|
||||
env(set(c.borrower, c.broker.brokerID, Number{500}),
|
||||
Sig(sfCounterpartySignature, c.lender),
|
||||
kInterestRate(TenthBips32{100'000}),
|
||||
kPaymentTotal(20),
|
||||
kPaymentInterval(86400 * 365),
|
||||
Fee(XRP(10)));
|
||||
env.close();
|
||||
|
||||
// Read broker state and compute both old and new minimums.
|
||||
auto const brokerSle = env.le(keylet::loanbroker(c.broker.brokerID));
|
||||
auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
|
||||
if (!BEAST_EXPECT(brokerSle) || !BEAST_EXPECT(vaultSle))
|
||||
return;
|
||||
|
||||
auto const coverAvail = brokerSle->at(sfCoverAvailable);
|
||||
auto const debtTotal = brokerSle->at(sfDebtTotal);
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
auto const debtScale = scale(debtTotal, asset);
|
||||
|
||||
// Sanity: debt scale differs from vault scale for this setup.
|
||||
BEAST_EXPECT(debtScale < vaultScale);
|
||||
|
||||
auto const oldMin = [&]() {
|
||||
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
|
||||
return roundToAsset(
|
||||
asset,
|
||||
tenthBipsOfValue(debtTotal, TenthBips32{c.brokerParams.coverRateMin}),
|
||||
debtScale);
|
||||
}();
|
||||
auto const newMin = minimumBrokerCover(
|
||||
debtTotal, TenthBips32{c.brokerParams.coverRateMin}, vaultSle);
|
||||
|
||||
// The new (vaultScale) minimum must be strictly larger than
|
||||
// the old (debtScale) minimum — that is the gap the amendment
|
||||
// closes.
|
||||
Number const expectedNewMin{1330650518688500000, -15};
|
||||
Number const expectedOldMin{1330650518688472000, -15};
|
||||
BEAST_EXPECT(newMin == expectedNewMin);
|
||||
BEAST_EXPECT(oldMin == expectedOldMin);
|
||||
|
||||
// Try to withdraw so that remaining cover lands between the
|
||||
// two minimums: oldMin < target < newMin.
|
||||
auto const target = oldMin + (newMin - oldMin) / 2;
|
||||
auto const withdrawAmount = STAmount{asset, coverAvail - target};
|
||||
|
||||
if (withAmendment)
|
||||
{
|
||||
// CoverWithdraw now uses vaultScale: target < newMin
|
||||
// => FAILS.
|
||||
env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount),
|
||||
Ter(tecINSUFFICIENT_FUNDS));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Old CoverWithdraw uses debtScale: target > oldMin
|
||||
// => SUCCEEDS.
|
||||
env(coverWithdraw(c.lender, c.broker.brokerID, withdrawAmount));
|
||||
}
|
||||
env.close();
|
||||
});
|
||||
|
||||
// Scenario 3 — LoanSet
|
||||
//
|
||||
// Verify that LoanSet's minimum cover check uses vault scale (not the
|
||||
// raw unrounded tenthBipsOfValue). Before the amendment, LoanSet
|
||||
// used tenthBipsOfValue(newDebtTotal, coverRateMinimum) (no
|
||||
// roundToAsset), while clawback/withdraw used different formulas.
|
||||
// After the amendment all use minimumBrokerCover at vaultScale, and
|
||||
// rounding at a coarser scale can absorb a tiny debt increase —
|
||||
// allowing a loan that would otherwise be rejected.
|
||||
testcase("LoanSet minimum cover scale consistency");
|
||||
runTest(
|
||||
/*vaultDeposit=*/1'000, [&](Env& env, Ctx const& c) {
|
||||
// Create the tiny loan (scale -12) AND the big loan (scale
|
||||
// -11). Both loans are needed so that DebtTotal has a full
|
||||
// 16-digit mantissa — a "messy" value where roundToAsset at
|
||||
// vaultScale actually truncates digits and produces a
|
||||
// different result from the raw tenthBipsOfValue. With only
|
||||
// the big loan, DebtTotal has ~4 significant digits and
|
||||
// rounding at scale -11 is a no-op, masking the amendment's
|
||||
// effect.
|
||||
env(set(c.borrower, c.broker.brokerID, Number{1, -2}),
|
||||
Sig(sfCounterpartySignature, c.lender),
|
||||
kInterestRate(TenthBips32{0}),
|
||||
kPaymentTotal(1),
|
||||
kPaymentInterval(86400 * 365),
|
||||
Fee(XRP(10)));
|
||||
env.close();
|
||||
|
||||
env(set(c.borrower, c.broker.brokerID, Number{500}),
|
||||
Sig(sfCounterpartySignature, c.lender),
|
||||
kInterestRate(TenthBips32{100'000}),
|
||||
kPaymentTotal(20),
|
||||
kPaymentInterval(86400 * 365),
|
||||
Fee(XRP(10)));
|
||||
env.close();
|
||||
|
||||
// Clawback to reduce cover to the clawback transactor's
|
||||
// minimum. Pass the exact amount rather than relying on the
|
||||
// transactor to clip down; the setup matches Scenario 1 so
|
||||
// the same residual-cover values apply.
|
||||
Number const expectedCoverAfter = withAmendment ? Number{1330651855688460000, -15}
|
||||
: Number{1330651855688458000, -15};
|
||||
Number const clawbackAmount =
|
||||
Number{c.brokerParams.coverDeposit} - expectedCoverAfter;
|
||||
env(coverClawback(c.issuer),
|
||||
kLoanBrokerId(c.broker.brokerID),
|
||||
kAmount(c.iou(clawbackAmount)));
|
||||
env.close();
|
||||
|
||||
// Verify scales.
|
||||
auto const vaultSle = env.le(keylet::vault(c.broker.vaultID));
|
||||
if (!BEAST_EXPECT(vaultSle))
|
||||
return;
|
||||
auto const vaultScale = getAssetsTotalScale(vaultSle);
|
||||
BEAST_EXPECT(vaultScale == -11);
|
||||
|
||||
// Now try to create a tiny additional loan. Principal is
|
||||
// 1e-11 (the smallest value that survives the precision
|
||||
// check at loanScale = vaultScale = -11), with 0% interest
|
||||
// and 1 payment.
|
||||
//
|
||||
// The tiny debt increase adds ~1.337e-12 to the unrounded
|
||||
// minimum.
|
||||
// - Without the amendment: the old LoanSet formula rounds
|
||||
// up during tenthBipsOfValue (16-digit Number
|
||||
// normalisation), pushing the minimum past the cover left
|
||||
// by clawback => tecINSUFFICIENT_FUNDS.
|
||||
// - With the amendment: minimumBrokerCover rounds at
|
||||
// vaultScale=-11, which absorbs the tiny increase — the
|
||||
// rounded minimum stays the same => tesSUCCESS.
|
||||
auto const tinyPrincipal = Number{1, -11};
|
||||
|
||||
if (withAmendment)
|
||||
{
|
||||
env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
|
||||
Sig(sfCounterpartySignature, c.lender),
|
||||
kInterestRate(TenthBips32{0}),
|
||||
kPaymentTotal(1),
|
||||
kPaymentInterval(86400 * 365),
|
||||
Fee(XRP(10)));
|
||||
}
|
||||
else
|
||||
{
|
||||
env(set(c.borrower, c.broker.brokerID, tinyPrincipal),
|
||||
Sig(sfCounterpartySignature, c.lender),
|
||||
kInterestRate(TenthBips32{0}),
|
||||
kPaymentTotal(1),
|
||||
kPaymentInterval(86400 * 365),
|
||||
Fee(XRP(10)),
|
||||
Ter(tecINSUFFICIENT_FUNDS));
|
||||
}
|
||||
env.close();
|
||||
});
|
||||
}
|
||||
|
||||
void
|
||||
runAmendmentIndependent()
|
||||
{
|
||||
@@ -7919,6 +8327,7 @@ protected:
|
||||
testOverpaymentManagementFee(features);
|
||||
testIssuerIsBorrower(features);
|
||||
testIntegerScalePrincipalSticks(features);
|
||||
testMinimumBrokerCoverConsistency(features);
|
||||
|
||||
// RIPD regressions
|
||||
testRIPD3831(features);
|
||||
|
||||
Reference in New Issue
Block a user