fix: Use the consistent scale for debtTotal (#7093)

This commit is contained in:
Vito
2026-05-19 19:58:12 +02:00
parent 3263132fb4
commit 1dbab8e344
6 changed files with 516 additions and 25 deletions

View File

@@ -204,6 +204,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(
Asset const& asset,
Number const& debtTotal,
TenthBips32 coverRateMinimum,
SLE::const_ref vaultSle)
{
NumberRoundModeGuard const mg(Number::RoundingMode::Upward);
return roundToAsset(
asset, tenthBipsOfValue(debtTotal, coverRateMinimum), getAssetsTotalScale(vaultSle));
}
TER
checkLoanGuards(
Asset const& vaultAsset,

View File

@@ -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,28 @@ 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(
vaultAsset,
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 +297,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 +360,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;

View File

@@ -142,6 +142,15 @@ 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(
vaultAsset,
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);

View File

@@ -312,6 +312,10 @@ LoanPay::doApply()
TenthBips32 const coverRateMinimum{brokerSle->at(sfCoverRateMinimum)};
auto debtTotalProxy = brokerSle->at(sfDebtTotal);
// We should use vaultScale for vault-related fields (e.g. DebtTotal), not
// loanScale, and vice versa.
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
@@ -321,14 +325,20 @@ 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) &&
auto const minCover = [&]() {
if (view.rules().enabled(fixCleanup3_2_0))
{
return minimumBrokerCover(
asset, 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);
}();
@@ -424,10 +434,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);

View File

@@ -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(vaultAsset, 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;

View File

@@ -8096,6 +8096,437 @@ protected:
to_string(tolerance));
}
// Verify that LoanPay's minimum cover check uses vault scale (not loan
// scale) when the fixCleanup3_2_0 amendment is enabled.
// Before the amendment, different loans could produce different fee
// routing decisions for the same broker-level cover/debt state.
void
testMinimumBrokerCoverScale(FeatureBitset features)
{
testcase("LoanPay minimum cover scale consistency");
using namespace jtx;
using namespace loan;
using namespace loanBroker;
// Run the scenario with the amendment disabled (expect the bug)
// and enabled (expect consistency).
bool const withAmendment = features[fixCleanup3_2_0];
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).
// We need this so the issuer can claw cover below the withdraw
// transactor's minimum — the clawback minimum is less rounded
// before the amendment, which is exactly the gap we exploit.
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();
// Small vault deposit => vaultScale = -12.
// Cover deposit generous enough for LoanSet to succeed.
BrokerParameters const brokerParams{
.vaultDeposit = 1'000,
.debtMax = 0,
.coverRateMin =
TenthBips32{13'370}, // 13.37% — non-round rate produces a messier minimum
.coverDeposit = 5'000,
.managementFeeRate = TenthBips16{500}};
BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams);
Asset const asset{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(broker.brokerID));
if (!BEAST_EXPECT(brokerSle1))
return;
auto const tinyLoanSeq = brokerSle1->at(sfLoanSequence);
auto const tinyLoanKeylet = keylet::loan(broker.brokerID, tinyLoanSeq);
env(set(borrower, broker.brokerID, Number{1, -2}),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
if (!BEAST_EXPECT(env.le(tinyLoanKeylet)))
return;
// 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(broker.brokerID));
if (!BEAST_EXPECT(brokerSle2))
return;
auto const bigLoanSeq = brokerSle2->at(sfLoanSequence);
auto const bigLoanKeylet = keylet::loan(broker.brokerID, bigLoanSeq);
env(set(borrower, broker.brokerID, Number{500}),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{100'000}),
kPaymentTotal(20),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
if (!BEAST_EXPECT(env.le(bigLoanKeylet)))
return;
// Read scales.
auto const tinyLoanSle = env.le(tinyLoanKeylet);
auto const bigLoanSle = env.le(bigLoanKeylet);
if (!BEAST_EXPECT(tinyLoanSle) || !BEAST_EXPECT(bigLoanSle))
return;
auto const tinyLoanScale = tinyLoanSle->at(sfLoanScale);
auto const bigLoanScale = bigLoanSle->at(sfLoanScale);
// The tiny loan's scale is frozen at the vault's pre-big-loan
// scale, so it should be strictly smaller than the big loan's.
if (!BEAST_EXPECT(tinyLoanScale < bigLoanScale))
return;
auto const vaultSle = env.le(keylet::vault(broker.vaultID));
if (!BEAST_EXPECT(vaultSle))
return;
auto const vaultScale = getAssetsTotalScale(vaultSle);
// After the big loan is created the vault absorbs its value,
// pushing vaultScale up to match bigLoanScale.
BEAST_EXPECT(bigLoanScale == vaultScale);
// Use issuer clawback (no specific amount) to reduce cover to
// the minimum the clawback transactor allows.
//
// Before the amendment the clawback minimum is the *unrounded*
// tenthBipsOfValue — strictly less than the rounded-at-vaultScale
// minimum that LoanPay uses for the big loan.
//
// With the amendment both clawback and LoanPay use the same
// rounded-at-vaultScale minimum (via minimumBrokerCover), so
// cover lands exactly at that threshold.
env(env.json(
coverClawback(issuer),
kLoanBrokerId(broker.brokerID),
Fee(kNone),
Seq(kNone),
Sig(kNone)));
env.close();
// Re-read the broker after cover reduction.
auto const brokerSle = env.le(keylet::loanbroker(broker.brokerID));
if (!BEAST_EXPECT(brokerSle))
return;
BEAST_EXPECT(vaultScale == -11);
BEAST_EXPECT(tinyLoanScale == -12);
BEAST_EXPECT(bigLoanScale == -11);
if (withAmendment)
{
auto expectedValue = Number{1330651855688460000, -15};
BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedValue);
}
else
{
auto expectedValue = Number{1330651855688458000, -15};
BEAST_EXPECT(brokerSle->at(sfCoverAvailable) == expectedValue);
}
// Pay each loan independently and observe the fee routing.
auto feeGoesToPseudo = [&](Keylet const& loanKeylet) {
auto const pseudoAcct = Account("pseudo", brokerSle->at(sfAccount));
auto const pseudoBefore = env.balance(pseudoAcct, iou);
auto const payLoan = env.le(loanKeylet);
if (!BEAST_EXPECT(payLoan))
return false;
auto const payment = payLoan->at(sfPeriodicPayment);
auto const serviceFee = payLoan->at(sfLoanServiceFee);
auto const payAmt = STAmount{asset, payment + serviceFee};
env(loan::pay(borrower, loanKeylet.key, payAmt), Fee(XRP(10)));
env.close();
auto const pseudoAfter = env.balance(pseudoAcct, iou);
return pseudoAfter.number() > pseudoBefore.number();
};
if (withAmendment)
{
// With the fix, both LoanPay and clawback use the same
// vaultScale minimum. Cover == minAtVaultScale, so both
// loans pass the check => fee to owner for both.
BEAST_EXPECT(!feeGoesToPseudo(bigLoanKeylet));
BEAST_EXPECT(!feeGoesToPseudo(tinyLoanKeylet));
}
else
{
// Without the fix:
// - Clawback reduced cover to the *unrounded* minimum
// (raw tenthBipsOfValue, at precision -12).
// - Paying the big loan: LoanPay uses bigLoanScale=-11,
// rounds up => a larger minimum => cover < min => pseudo.
// - Paying the tiny loan: LoanPay uses tinyLoanScale=-12,
// rounds up at -12 (no-op) => min == cover => owner.
BEAST_EXPECT(feeGoesToPseudo(bigLoanKeylet));
BEAST_EXPECT(!feeGoesToPseudo(tinyLoanKeylet));
}
}
// Verify that LoanBrokerCoverWithdraw's minimum cover check uses vault
// scale (not scale(debtTotal, asset)) when fixCleanup3_2_0 is enabled.
// Before the amendment, CoverWithdraw used
// roundToAsset(asset, tenthBipsOfValue(debt, rate), scale(debt, asset))
// which could disagree with LoanPay's minimum (which used loanScale).
// After the amendment all transactors use minimumBrokerCover at vaultScale.
void
testCoverWithdrawMinimumConsistency(FeatureBitset features)
{
testcase("CoverWithdraw minimum cover scale consistency");
using namespace jtx;
using namespace loan;
using namespace loanBroker;
bool const withAmendment = features[fixCleanup3_2_0];
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();
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();
// 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.
BrokerParameters const brokerParams{
.vaultDeposit = 100'000,
.debtMax = 0,
.coverRateMin = TenthBips32{13'370},
.coverDeposit = 5'000,
.managementFeeRate = TenthBips16{500}};
BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams);
Asset const asset{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(borrower, broker.brokerID, Number{500}),
Sig(sfCounterpartySignature, 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(broker.brokerID));
auto const vaultSle = env.le(keylet::vault(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{brokerParams.coverRateMin}),
debtScale);
}();
auto const newMin =
minimumBrokerCover(asset, debtTotal, TenthBips32{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(lender, broker.brokerID, withdrawAmount), Ter(tecINSUFFICIENT_FUNDS));
}
else
{
// Old CoverWithdraw uses debtScale: target > oldMin → SUCCEEDS.
env(coverWithdraw(lender, broker.brokerID, withdrawAmount));
}
env.close();
}
// Verify that LoanSet's minimum cover check uses vault scale (not the
// raw unrounded tenthBipsOfValue) when fixCleanup3_2_0 is enabled.
//
// Before the amendment, LoanSet used:
// tenthBipsOfValue(newDebtTotal, coverRateMinimum)
// (no roundToAsset), while the clawback/withdraw transactors 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.
void
testLoanSetMinimumConsistency(FeatureBitset features)
{
testcase("LoanSet minimum cover scale consistency");
using namespace jtx;
using namespace loan;
using namespace loanBroker;
bool const withAmendment = features[fixCleanup3_2_0];
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();
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();
// Same broker parameters as testMinimumBrokerCoverScale.
BrokerParameters const brokerParams{
.vaultDeposit = 1'000,
.debtMax = 0,
.coverRateMin = TenthBips32{13'370},
.coverDeposit = 5'000,
.managementFeeRate = TenthBips16{500}};
BrokerInfo const broker = createVaultAndBroker(env, iou, lender, brokerParams);
// Create the tiny loan (scale -12) AND the big loan (scale -11),
// identical to testMinimumBrokerCoverScale. 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(borrower, broker.brokerID, Number{1, -2}),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
env.close();
env(set(borrower, broker.brokerID, Number{500}),
Sig(sfCounterpartySignature, 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.
env(env.json(
coverClawback(issuer),
kLoanBrokerId(broker.brokerID),
Fee(kNone),
Seq(kNone),
Sig(kNone)));
env.close();
// Verify scales.
auto const vaultSle = env.le(keylet::vault(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(borrower, broker.brokerID, tinyPrincipal),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(86400 * 365),
Fee(XRP(10)));
}
else
{
env(set(borrower, broker.brokerID, tinyPrincipal),
Sig(sfCounterpartySignature, lender),
kInterestRate(TenthBips32{0}),
kPaymentTotal(1),
kPaymentInterval(86400 * 365),
Fee(XRP(10)),
Ter(tecINSUFFICIENT_FUNDS));
}
env.close();
}
public:
void
run() override
@@ -8107,6 +8538,12 @@ public:
testOverpaymentPreFixBranch();
testDosLoanPay(all_ | fixCleanup3_1_3);
testDosLoanPay(all_ - fixCleanup3_1_3);
testMinimumBrokerCoverScale(all_);
testMinimumBrokerCoverScale(all_ - fixCleanup3_2_0);
testCoverWithdrawMinimumConsistency(all_);
testCoverWithdrawMinimumConsistency(all_ - fixCleanup3_2_0);
testLoanSetMinimumConsistency(all_);
testLoanSetMinimumConsistency(all_ - fixCleanup3_2_0);
for (auto const& features : amendmentCombinations({fixCleanup3_2_0, featureMPTokensV2}))
runAmendmentSensitive(features);
}