diff --git a/src/libxrpl/tx/transactors/lending/LoanManage.cpp b/src/libxrpl/tx/transactors/lending/LoanManage.cpp index a312dba3b3..7bccbd71ec 100644 --- a/src/libxrpl/tx/transactors/lending/LoanManage.cpp +++ b/src/libxrpl/tx/transactors/lending/LoanManage.cpp @@ -165,8 +165,6 @@ LoanManage::defaultLoan( return std::min(covered, coverAvailable); }(); - auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered; - // Update the Vault object: // The vault may be at a different scale than the loan. Reduce rounding @@ -174,7 +172,64 @@ LoanManage::defaultLoan( // scale. auto const vaultScale = getAssetsTotalScale(vaultSle); + // Under fixCleanup3_4_0, both vault-side fields are mutated through a + // single asset-typed STAmount pair. `amount = STAmount{vaultAsset, + // defaultCovered}` is applied to both sfAssetsTotal and + // sfAssetsAvailable, and `writeOff = STAmount{vaultAsset, + // totalDefaultAmount}` is applied to sfAssetsTotal only. Because the + // shared `amount` is normalized to STAmount precision exactly once and + // absorbed by both fields symmetrically, sfAssetsAvailable cannot + // overshoot sfAssetsTotal from arithmetic alone -- the residual is + // exactly `-writeOff` on Total. Pre-amendment, the two fields were + // adjusted via values obtained through different Number->STAmount + // paths (vaultDefaultAmount rounded down to vaultScale on one side, + // defaultCovered kept at the finer loan scale on the other), so the + // normalization was asymmetric and a dust-reconciliation snap was + // required to paper over the resulting cross-side mismatch -- a snap + // that itself minted phantom assets on sfAssetsTotal. See the + // sibling change in LoanPay for the same fix on the payment path. + bool const useUnifiedAssetArithmetic = view.rules().enabled(fixCleanup3_4_0); + + if (useUnifiedAssetArithmetic) { + // Prior to realizing the default, the vault must already carry + // at least this loan's exposure. A violation here is a corrupt + // ledger, not a transaction-input error. + // + // This guard is strictly stronger than the pre-amendment + // `vaultTotalBefore < vaultDefaultAmount` form, but activation is + // safe: the ValidVault invariant continuously enforces Total >= + // Available (so Total - Available >= 0 on every ledger), and the + // pre-amendment cross-scale rounding could only inflate the + // difference Total - Available, never deflate it. Any ledger + // reaching this point post-activation will therefore already + // satisfy the stronger form. + Number const vaultTotalBefore = *vaultSle->at(sfAssetsTotal); + Number const vaultAvailableBefore = *vaultSle->at(sfAssetsAvailable); + if (vaultTotalBefore - vaultAvailableBefore < totalDefaultAmount) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Vault exposure is less than the loan default amount"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + + // Mutate the two vault fields through a single asset-typed pair + // (see the block comment on useUnifiedAssetArithmetic above for + // why this is safe): + // (1) write off totalDefaultAmount from Total only; + // (2) add defaultCovered symmetrically to both fields. + STAmount const amount{vaultAsset, defaultCovered}; + STAmount const writeOff{vaultAsset, totalDefaultAmount}; + + vaultSle->at(sfAssetsTotal) += amount - writeOff; + vaultSle->at(sfAssetsAvailable) += amount; + } + else + { + // Pre-amendment behavior. + auto const vaultDefaultAmount = totalDefaultAmount - defaultCovered; + // Decrease the Total Value of the Vault: auto vaultTotalProxy = vaultSle->at(sfAssetsTotal); auto vaultAvailableProxy = vaultSle->at(sfAssetsAvailable); @@ -190,8 +245,8 @@ LoanManage::defaultLoan( auto const vaultDefaultRounded = roundToAsset( vaultAsset, vaultDefaultAmount, vaultScale, Number::RoundingMode::Downward); vaultTotalProxy -= vaultDefaultRounded; - // Increase the Asset Available of the Vault by liquidated First-Loss - // Capital and any unclaimed funds amount: + // Increase the Asset Available of the Vault by liquidated + // First-Loss Capital and any unclaimed funds amount: vaultAvailableProxy += defaultCovered; if (*vaultAvailableProxy > *vaultTotalProxy && !vaultAsset.integral()) { @@ -203,8 +258,8 @@ LoanManage::defaultLoan( << "(" << difference.exponent() << ")"; if (vaultAvailableProxy.value().exponent() - difference.exponent() > 13) { - // If the difference is dust, bring the total up to match - // the available + // If the difference is dust, bring the total up to + // match the available JLOG(j.debug()) << "Difference between vault assets available and total is " "dust. Set both to the larger value."; vaultTotalProxy = vaultAvailableProxy; @@ -219,24 +274,24 @@ LoanManage::defaultLoan( return tecINTERNAL; // LCOV_EXCL_STOP } - - // The loss has been realized - if (loanSle->isFlag(lsfLoanImpaired)) - { - auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); - if (vaultLossUnrealizedProxy < totalDefaultAmount) - { - // LCOV_EXCL_START - JLOG(j.warn()) << "Vault unrealized loss is less than the default amount"; - return tefBAD_LEDGER; - // LCOV_EXCL_STOP - } - adjustImpreciseNumber( - vaultLossUnrealizedProxy, -totalDefaultAmount, vaultAsset, vaultScale); - } - view.update(vaultSle); } + // The loss has been realized + if (loanSle->isFlag(lsfLoanImpaired)) + { + auto vaultLossUnrealizedProxy = vaultSle->at(sfLossUnrealized); + if (vaultLossUnrealizedProxy < totalDefaultAmount) + { + // LCOV_EXCL_START + JLOG(j.warn()) << "Vault unrealized loss is less than the default amount"; + return tefBAD_LEDGER; + // LCOV_EXCL_STOP + } + adjustImpreciseNumber( + vaultLossUnrealizedProxy, -totalDefaultAmount, vaultAsset, vaultScale); + } + view.update(vaultSle); + // Update the LoanBroker object: { diff --git a/src/test/app/lending/LoanRounding_test.cpp b/src/test/app/lending/LoanRounding_test.cpp index 5e69c9f79e..3a26134489 100644 --- a/src/test/app/lending/LoanRounding_test.cpp +++ b/src/test/app/lending/LoanRounding_test.cpp @@ -987,7 +987,7 @@ public: { runAmendmentIndependent(); for (auto const& features : jtx::amendmentCombinations( - {fixCleanup3_1_3, fixCleanup3_2_0, featureMPTokensV2}, all_)) + {fixCleanup3_1_3, fixCleanup3_2_0, fixCleanup3_4_0, featureMPTokensV2}, all_)) runAmendmentSensitive(features); } };