mirror of
https://github.com/XRPLF/rippled.git
synced 2026-06-06 10:16:45 +00:00
Always round cover minimum calculations up
- Addresses RIPD-4016. - Add and update testRoundingAllowsUndercoverage() unit test from ticket.
This commit is contained in:
@@ -86,6 +86,7 @@ protected:
|
||||
Number
|
||||
maxCoveredLoanValue(Number const& currentDebt) const
|
||||
{
|
||||
NumberRoundModeGuard mg(Number::downward);
|
||||
auto debtLimit =
|
||||
coverDeposit * tenthBipsPerUnity.value() / coverRateMin.value();
|
||||
|
||||
@@ -2059,6 +2060,7 @@ protected:
|
||||
: std::max(
|
||||
broker.vaultScale(env),
|
||||
state.principalOutstanding.exponent())));
|
||||
NumberRoundModeGuard mg(Number::upward);
|
||||
auto const defaultAmount = roundToAsset(
|
||||
broker.asset,
|
||||
std::min(
|
||||
@@ -6499,6 +6501,94 @@ protected:
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
testRoundingAllowsUndercoverage()
|
||||
{
|
||||
testcase("Minimum cover rounding allows undercoverage (XRP)");
|
||||
|
||||
using namespace jtx;
|
||||
using namespace loanBroker;
|
||||
|
||||
Env env(*this, all);
|
||||
|
||||
Account const lender{"lender"};
|
||||
Account const borrower{"borrower"};
|
||||
|
||||
auto const asset = xrpIssue();
|
||||
|
||||
env.fund(XRP(200'000), lender, borrower);
|
||||
env.close();
|
||||
|
||||
// Vault with XRP asset
|
||||
Vault vault{env};
|
||||
auto [vaultCreate, vaultKeylet] =
|
||||
vault.create({.owner = lender, .asset = xrpIssue()});
|
||||
env(vaultCreate);
|
||||
env.close();
|
||||
BEAST_EXPECT(env.le(vaultKeylet));
|
||||
|
||||
// Seed the vault with XRP so it can fund the loan principal
|
||||
PrettyAsset const xrpAsset{xrpIssue(), 1};
|
||||
|
||||
BrokerParameters const brokerParams{
|
||||
.vaultDeposit = 1'000,
|
||||
.debtMax = Number{0},
|
||||
.coverRateMin = TenthBips32{10'000},
|
||||
.coverDeposit = 82,
|
||||
};
|
||||
|
||||
auto const brokerInfo =
|
||||
createVaultAndBroker(env, xrpAsset, lender, brokerParams);
|
||||
// Create a loan with principal 804 XRP and 0% interest (so
|
||||
// DebtTotal increases by exactly 804)
|
||||
env(loan::set(borrower, brokerInfo.brokerID, xrpAsset(804).value()),
|
||||
loan::interestRate(TenthBips32(0)),
|
||||
sig(sfCounterpartySignature, lender),
|
||||
fee(env.current()->fees().base * 2));
|
||||
BEAST_EXPECT(env.ter() == tesSUCCESS);
|
||||
env.close();
|
||||
|
||||
// Verify DebtTotal is exactly 804
|
||||
if (auto const brokerSle =
|
||||
env.le(keylet::loanbroker(brokerInfo.brokerID));
|
||||
BEAST_EXPECT(brokerSle))
|
||||
{
|
||||
std::cout << *brokerSle << std::endl;
|
||||
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
|
||||
}
|
||||
|
||||
// Attempt to withdraw 2 XRP to self, leaving 80 XRP CoverAvailable.
|
||||
// The minimum is 80.4 XRP, which rounds up to 81 XRP, so this fails.
|
||||
env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(2).value()),
|
||||
ter(tecINSUFFICIENT_FUNDS));
|
||||
BEAST_EXPECT(env.ter() == tecINSUFFICIENT_FUNDS);
|
||||
env.close();
|
||||
|
||||
// Attempt to withdraw 1 XRP to self, leaving 81 XRP CoverAvailable.
|
||||
// because that leaves sufficient cover, this succeeds
|
||||
env(coverWithdraw(lender, brokerInfo.brokerID, xrpAsset(1).value()));
|
||||
BEAST_EXPECT(env.ter() == tesSUCCESS);
|
||||
env.close();
|
||||
|
||||
// Validate CoverAvailable == 80 XRP and DebtTotal remains 804
|
||||
if (auto const brokerSle =
|
||||
env.le(keylet::loanbroker(brokerInfo.brokerID));
|
||||
BEAST_EXPECT(brokerSle))
|
||||
{
|
||||
std::cout << *brokerSle << std::endl;
|
||||
BEAST_EXPECT(
|
||||
brokerSle->at(sfCoverAvailable) == xrpAsset(81).value());
|
||||
BEAST_EXPECT(brokerSle->at(sfDebtTotal) == Number(804));
|
||||
|
||||
// Also demonstrate that the true minimum (804 * 10%) exceeds 80
|
||||
auto const theoreticalMin =
|
||||
tenthBipsOfValue(Number(804), TenthBips32(10'000));
|
||||
std::cout << "Theoretical min cover: " << theoreticalMin
|
||||
<< std::endl;
|
||||
BEAST_EXPECT(Number(804, -1) == theoreticalMin);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void
|
||||
run() override
|
||||
@@ -6542,6 +6632,7 @@ public:
|
||||
testRIPD3831();
|
||||
testRIPD3459();
|
||||
testRIPD3901();
|
||||
testRoundingAllowsUndercoverage();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -133,9 +133,15 @@ determineClawAmount(
|
||||
Asset const& vaultAsset,
|
||||
std::optional<STAmount> const& amount)
|
||||
{
|
||||
auto const maxClawAmount = sleBroker[sfCoverAvailable] -
|
||||
tenthBipsOfValue(sleBroker[sfDebtTotal],
|
||||
TenthBips32(sleBroker[sfCoverRateMinimum]));
|
||||
auto const maxClawAmount = [&]() {
|
||||
// Always round the minimum required up
|
||||
NumberRoundModeGuard mg1(Number::upward);
|
||||
auto const minRequiredCover = tenthBipsOfValue(
|
||||
sleBroker[sfDebtTotal], TenthBips32(sleBroker[sfCoverRateMinimum]));
|
||||
// The subtraction probably won't round, but round down if it does.
|
||||
NumberRoundModeGuard mg2(Number::downward);
|
||||
return sleBroker[sfCoverAvailable] - minRequiredCover;
|
||||
}();
|
||||
if (maxClawAmount <= beast::zero)
|
||||
return Unexpected(tecINSUFFICIENT_FUNDS);
|
||||
|
||||
|
||||
@@ -99,11 +99,17 @@ LoanBrokerCoverWithdraw::preclaim(PreclaimContext const& ctx)
|
||||
auto const coverAvail = sleBroker->at(sfCoverAvailable);
|
||||
// Cover Rate is in 1/10 bips units
|
||||
auto const currentDebtTotal = sleBroker->at(sfDebtTotal);
|
||||
auto const minimumCover = roundToAsset(
|
||||
vaultAsset,
|
||||
tenthBipsOfValue(
|
||||
currentDebtTotal, TenthBips32(sleBroker->at(sfCoverRateMinimum))),
|
||||
currentDebtTotal.exponent());
|
||||
auto const minimumCover = [&]() {
|
||||
// Always round the minimum required up.
|
||||
// Applies to `tenthBipsOfValue` as well as `roundToAsset`.
|
||||
NumberRoundModeGuard mg(Number::upward);
|
||||
return roundToAsset(
|
||||
vaultAsset,
|
||||
tenthBipsOfValue(
|
||||
currentDebtTotal,
|
||||
TenthBips32(sleBroker->at(sfCoverRateMinimum))),
|
||||
currentDebtTotal.exponent());
|
||||
}();
|
||||
if (coverAvail < amount)
|
||||
return tecINSUFFICIENT_FUNDS;
|
||||
if ((coverAvail - amount) < minimumCover)
|
||||
|
||||
@@ -148,20 +148,24 @@ LoanManage::defaultLoan(
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
TenthBips32 const coverRateLiquidation{
|
||||
brokerSle->at(sfCoverRateLiquidation)};
|
||||
auto const defaultCovered = 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(
|
||||
tenthBipsOfValue(
|
||||
brokerDebtTotalProxy.value(), coverRateMinimum),
|
||||
coverRateLiquidation),
|
||||
totalDefaultAmount),
|
||||
loanScale);
|
||||
auto const defaultCovered = [&]() {
|
||||
// Always round the minimum required up.
|
||||
NumberRoundModeGuard mg(Number::upward);
|
||||
auto const minimumCover =
|
||||
tenthBipsOfValue(brokerDebtTotalProxy.value(), coverRateMinimum);
|
||||
// Round the liquidation amount up, too
|
||||
return 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 vaultDefaultAmount = totalDefaultAmount - defaultCovered;
|
||||
|
||||
|
||||
@@ -253,12 +253,16 @@ LoanPay::doApply()
|
||||
//
|
||||
// Normally freeze status is checked in preflight, but we do it here to
|
||||
// avoid duplicating the check. It'll claim a fee either way.
|
||||
bool const sendBrokerFeeToOwner = coverAvailableProxy >=
|
||||
roundToAsset(asset,
|
||||
tenthBipsOfValue(
|
||||
debtTotalProxy.value(), coverRateMinimum),
|
||||
loanScale) &&
|
||||
!isDeepFrozen(view, brokerOwner, asset);
|
||||
bool const sendBrokerFeeToOwner = [&]() {
|
||||
// Always round the minimum required up.
|
||||
NumberRoundModeGuard mg(Number::upward);
|
||||
return coverAvailableProxy >=
|
||||
roundToAsset(
|
||||
asset,
|
||||
tenthBipsOfValue(debtTotalProxy.value(), coverRateMinimum),
|
||||
loanScale) &&
|
||||
!isDeepFrozen(view, brokerOwner, asset);
|
||||
}();
|
||||
|
||||
auto const brokerPayee =
|
||||
sendBrokerFeeToOwner ? brokerOwner : brokerPseudoAccount;
|
||||
|
||||
@@ -514,11 +514,16 @@ LoanSet::doApply()
|
||||
return tecLIMIT_EXCEEDED;
|
||||
}
|
||||
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
|
||||
if (brokerSle->at(sfCoverAvailable) <
|
||||
tenthBipsOfValue(newDebtTotal, coverRateMinimum))
|
||||
{
|
||||
JLOG(j_.warn()) << "Insufficient first-loss capital to cover the loan.";
|
||||
return tecINSUFFICIENT_FUNDS;
|
||||
// Always round the minimum required up.
|
||||
NumberRoundModeGuard mg(Number::upward);
|
||||
if (brokerSle->at(sfCoverAvailable) <
|
||||
tenthBipsOfValue(newDebtTotal, coverRateMinimum))
|
||||
{
|
||||
JLOG(j_.warn())
|
||||
<< "Insufficient first-loss capital to cover the loan.";
|
||||
return tecINSUFFICIENT_FUNDS;
|
||||
}
|
||||
}
|
||||
|
||||
adjustOwnerCount(view, borrowerSle, 1, j_);
|
||||
|
||||
Reference in New Issue
Block a user