From 1dbab8e3449e4f41629e7271694d8a6a4cec64bf Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 19 May 2026 19:58:12 +0200 Subject: [PATCH] fix: Use the consistent scale for debtTotal (#7093) --- include/xrpl/ledger/helpers/LendingHelpers.h | 15 + .../lending/LoanBrokerCoverClawback.cpp | 32 +- .../lending/LoanBrokerCoverWithdraw.cpp | 9 + .../tx/transactors/lending/LoanPay.cpp | 30 +- .../tx/transactors/lending/LoanSet.cpp | 18 +- src/test/app/Loan_test.cpp | 437 ++++++++++++++++++ 6 files changed, 516 insertions(+), 25 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index b5d582dee4..3e6ff1f378 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -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, diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp index 56fc068786..3ccb29f30f 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -160,15 +161,28 @@ Expected determineClawAmount( SLE const& sleBroker, Asset const& vaultAsset, - std::optional const& amount) + std::optional 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; diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp index 209014d61c..f6fb837809 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp @@ -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); diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index aaf2b74dcb..39d2d41697 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -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); diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index a3b71fd20d..3454f44780 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -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; diff --git a/src/test/app/Loan_test.cpp b/src/test/app/Loan_test.cpp index 032225c0cc..3651276cd0 100644 --- a/src/test/app/Loan_test.cpp +++ b/src/test/app/Loan_test.cpp @@ -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); }