From bb820683cb9f272c7aa572c9bbb2858e386f3512 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:46:20 +0200 Subject: [PATCH] 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. --- include/xrpl/ledger/helpers/LendingHelpers.h | 64 ++++++-- src/libxrpl/ledger/helpers/LendingHelpers.cpp | 132 ++++++++++++++++ .../lending/LoanBrokerCoverClawback.cpp | 54 +++++-- .../lending/LoanBrokerCoverDeposit.cpp | 20 ++- .../lending/LoanBrokerCoverWithdraw.cpp | 43 ++++-- .../tx/transactors/lending/LoanBrokerSet.cpp | 7 +- .../tx/transactors/lending/LoanPay.cpp | 4 +- .../tx/transactors/lending/LoanSet.cpp | 3 +- src/test/app/lending/LendingHelpers_test.cpp | 33 ++++ src/test/app/lending/LoanBroker_test.cpp | 142 +++++++++++++++++- src/tests/libxrpl/protocol/VaultGridTests.cpp | 72 +++++++++ 11 files changed, 522 insertions(+), 52 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index 3887b10120..148f995f5b 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -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( diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index c15078ce40..f704dc8a68 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -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) { diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp index b914f3cf24..8b35214068 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverClawback.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -158,7 +159,7 @@ determineAsset( std::expected determineClawAmount( - SLE const& sleBroker, + SLE::const_ref sleBroker, Asset const& vaultAsset, std::optional 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 @@ -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; diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp index 09ab03347a..2f8caeec04 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverDeposit.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -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]; diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp index 88b6f8c38b..b226b3c384 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerCoverWithdraw.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -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); diff --git a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp index 1ab4eb2ce0..cdfd3e1ef5 100644 --- a/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanBrokerSet.cpp @@ -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) << "."; diff --git a/src/libxrpl/tx/transactors/lending/LoanPay.cpp b/src/libxrpl/tx/transactors/lending/LoanPay.cpp index 624c4d0a84..1c77c86cef 100644 --- a/src/libxrpl/tx/transactors/lending/LoanPay.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanPay.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -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); } diff --git a/src/libxrpl/tx/transactors/lending/LoanSet.cpp b/src/libxrpl/tx/transactors/lending/LoanSet.cpp index 243a065d7a..91bad38df2 100644 --- a/src/libxrpl/tx/transactors/lending/LoanSet.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanSet.cpp @@ -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); } diff --git a/src/test/app/lending/LendingHelpers_test.cpp b/src/test/app/lending/LendingHelpers_test.cpp index 60a62c651d..e2ef5ba6a3 100644 --- a/src/test/app/lending/LendingHelpers_test.cpp +++ b/src/test/app/lending/LendingHelpers_test.cpp @@ -20,9 +20,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -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(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 diff --git a/src/test/app/lending/LoanBroker_test.cpp b/src/test/app/lending/LoanBroker_test.cpp index 3bcda42c7e..5746b83718 100644 --- a/src/test/app/lending/LoanBroker_test.cpp +++ b/src/test/app/lending/LoanBroker_test.cpp @@ -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(); diff --git a/src/tests/libxrpl/protocol/VaultGridTests.cpp b/src/tests/libxrpl/protocol/VaultGridTests.cpp index f3f155664c..9cff02ef9a 100644 --- a/src/tests/libxrpl/protocol/VaultGridTests.cpp +++ b/src/tests/libxrpl/protocol/VaultGridTests.cpp @@ -1,10 +1,13 @@ #include #include #include +#include #include #include #include #include +#include +#include #include #include #include @@ -44,6 +47,15 @@ makeVault( return vault; } +std::shared_ptr +makeBroker(Asset const& asset, Number const& coverAvailable) +{ + auto broker = std::make_shared(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