From 3c51e3dad174f9c43bf271bf0a25f947dab7a70c Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:40:10 +0200 Subject: [PATCH] feat: Centralize Vault balance mutations in VaultHelpers Add addVaultAssets, removeVaultAssets (two overloads: plain accountSend and doWithdraw-based), clawbackVaultAssets, and moveVaultAssets as the single points through which a Vault's sfAssetsTotal/sfAssetsAvailable are mutated and funds move to/from its pseudo-account: - addVaultAssets increases both fields and transfers in from a sender. - removeVaultAssets/clawbackVaultAssets decrease both fields equally and transfer out; a FinalRemoval flag hard-resets both fields to exactly zero on a Vault's last withdrawal, since the discounted exchange-rate formula can produce values with more precision than the asset can canonically represent, and subtracting such a value would leave a non-canonical residual instead of an exact zero. - moveVaultAssets decreases only sfAssetsAvailable, for disbursements (e.g. a loan's principal and origination fee) where sfAssetsTotal independently grows via accrued interest. Also consolidate getAssetsTotalScale into VaultHelpers::getVaultScale, and move isRounded from LendingHelpers into STAmount.h alongside the other rounding utilities. --- include/xrpl/ledger/helpers/LendingHelpers.h | 14 +- include/xrpl/ledger/helpers/VaultHelpers.h | 183 ++++++++++++++++ include/xrpl/protocol/STAmount.h | 7 + src/libxrpl/ledger/helpers/LendingHelpers.cpp | 11 - src/libxrpl/ledger/helpers/VaultHelpers.cpp | 198 ++++++++++++++++++ 5 files changed, 390 insertions(+), 23 deletions(-) diff --git a/include/xrpl/ledger/helpers/LendingHelpers.h b/include/xrpl/ledger/helpers/LendingHelpers.h index c69efff964..836d1c34c5 100644 --- a/include/xrpl/ledger/helpers/LendingHelpers.h +++ b/include/xrpl/ledger/helpers/LendingHelpers.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -216,14 +217,6 @@ adjustImpreciseNumber( value = 0; } -inline int -getAssetsTotalScale(SLE::const_ref vaultSle) -{ - if (!vaultSle) - return Number::kMinExponent - 1; // LCOV_EXCL_LINE - 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. @@ -236,7 +229,7 @@ minimumBrokerCover(Number const& debtTotal, TenthBips32 coverRateMinimum, SLE::c return roundToAsset( vaultSle->at(sfAsset), tenthBipsOfValue(debtTotal, coverRateMinimum), - getAssetsTotalScale(vaultSle)); + getVaultScale(vaultSle)); } TER @@ -610,9 +603,6 @@ computeLoanProperties( TenthBips32 managementFeeRate, std::int32_t minimumScale); -bool -isRounded(Asset const& asset, Number const& value, std::int32_t scale); - // Indicates what type of payment is being made. // regular, late, and full are mutually exclusive. // overpayment is an "add on" to a regular payment, and follows that path with diff --git a/include/xrpl/ledger/helpers/VaultHelpers.h b/include/xrpl/ledger/helpers/VaultHelpers.h index 5681cc57e8..19397c5806 100644 --- a/include/xrpl/ledger/helpers/VaultHelpers.h +++ b/include/xrpl/ledger/helpers/VaultHelpers.h @@ -1,10 +1,15 @@ #pragma once +#include +#include #include +#include #include #include #include #include +#include +#include #include @@ -123,4 +128,182 @@ isSoleShareholder(ReadView const& view, AccountID const& account, SLE::const_ref [[nodiscard]] VaultVersion getVaultVersion(SLE::const_ref vault); +/** + * Returns the scale (number of decimal places) at which a vault's + * sfAssetsTotal is maintained, derived from the vault's asset and its + * current sfAssetsTotal value. + * + * @param vault The vault SLE. + * + * @return The vault's scale, or `Number::kMinExponent - 1` if `vault` is + * null. + */ +[[nodiscard]] int +getVaultScale(SLE::const_ref vault); + +/** + * The single point through which assets are added to a Vault: updates the + * Vault's sfAssetsTotal and sfAssetsAvailable and transfers `amount` of the + * Vault's asset from `sender` to the Vault's pseudo-account. + * + * Callers are responsible for rounding `amount` and `valueDelta` to whatever + * scale is appropriate for their own accounting (e.g. current vs. posterior + * Vault scale); this helper does not perform any additional rounding. + * + * @param view The ledger view to apply changes to. + * @param vault The vault SLE. Must not be null. + * @param sender The account to transfer `amount` from. + * @param amount The amount to add to sfAssetsAvailable, and to transfer from + * `sender` to the Vault's pseudo-account. + * @param valueDelta The amount to add to sfAssetsTotal. May differ from + * `amount`, e.g. when recognizing a value change that is + * not fully backed by a matching cash transfer. May be + * negative (e.g. a default write-off, or a small rounding + * correction), unlike `amount`. + * @param j Journal for logging. + * + * @return TER on success or failure. + */ +[[nodiscard]] TER +addVaultAssets( + ApplyView& view, + SLE::ref vault, + AccountID const& sender, + STAmount const& amount, + STAmount const& valueDelta, + beast::Journal j); + +/** + * Signals that a removal is the last one possible for a Vault — i.e. it + * burns every outstanding share. removeVaultAssets uses this to hard-reset + * sfAssetsTotal and sfAssetsAvailable to exactly zero, rather than + * subtracting `amount`/`valueDelta` from them. + * + * This matters because the discounted exchange-rate formula used to compute + * a withdrawal's `amount` can produce a value with more decimal precision + * than the Vault's asset can canonically represent. Subtracting such a + * value from the field would leave a non-canonical residual instead of an + * exact zero, corrupting the ledger entry. A final removal is defined to + * exhaust the Vault's exposure entirely, so hard-resetting to zero is both + * simpler and correct — no residual dust is possible or desired. + */ +enum class FinalRemoval : bool { No = false, Yes = true }; + +/** + * The single point through which assets are clawed back from a Vault entirely: + * decreases the Vault's sfAssetsTotal and sfAssetsAvailable + * and transfers @p amount from the Vault's pseudo-account to @p recipient via + * a plain accountSend. + * + * Callers are responsible for rounding @p amount to whatever + * scale is appropriate for their own accounting; this helper does not + * perform any additional rounding. + * + * @param view The ledger view to apply changes to. + * @param vault The vault SLE. Must not be null. + * @param recipient The account to transfer `amount` to. Must already be + * able to hold the Vault's asset without further setup. + * @param amount The amount to clawback from the vault and transfer from the + * Vault's pseudo-account to `recipient`. Must be positive; + * callers must skip calling this helper entirely for a + * zero-amount clawback (unlike addVaultAssets/removeVaultAssets, + * which tolerate a zero `amount`). + * @param j Journal for logging. + * + * @return TER code. + */ +[[nodiscard]] TER +clawbackVaultAssets( + ApplyView& view, + SLE::ref vault, + AccountID const& recipient, + STAmount const& amount, + beast::Journal j); + +/** + * The single point through which assets are removed from a Vault entirely + * and withdrawn to a recipient that may not yet have a holding for the + * Vault's asset: decreases the Vault's sfAssetsTotal and sfAssetsAvailable + * and calls doWithdraw to transfer @p amount from the + * Vault's pseudo-account to @p dstAcct. + * + * Unlike clawbackVaultAssets, this relies solely on doWithdraw's own + * pre-transfer balance check rather than an additional post-transfer sanity + * check. + * + * Callers are responsible for rounding @p amount; this helper does not + * perform any additional rounding, except when `finalRemoval` is Yes (see + * FinalRemoval). + * + * @param ctx The apply-view context to apply changes to. + * @param vault The vault SLE. Must not be null. + * @param senderAcct The account that submitted the withdrawal transaction. + * @param dstAcct The account to transfer `amount` to; may equal `senderAcct`. + * @param priorBalance The XRP reserve base, passed through to doWithdraw for + * creating a holding for `dstAcct` when required. + * @param amount The amount to subtract from sfAssetsAvailable, and to + * transfer from the Vault's pseudo-account to `dstAcct`. + * Ignored (other than for the transfer) when `finalRemoval` is + * Yes. + * @param j Journal for logging. + * @param finalRemoval Whether this is the Vault's final removal (see + * FinalRemoval). + * + * @return TER from doWithdraw. + */ +[[nodiscard]] TER +removeVaultAssets( + ApplyViewContext ctx, + SLE::ref vault, + AccountID const& senderAcct, + AccountID const& dstAcct, + XRPAmount priorBalance, + STAmount const& amount, + beast::Journal j, + FinalRemoval finalRemoval = FinalRemoval::No); + +/** + * The single point through which cash is moved out of a Vault's + * sfAssetsAvailable to multiple recipients in a single atomic payment, e.g. + * a loan's principal and origination fee, without necessarily shrinking the + * Vault's total exposure: updates sfAssetsAvailable (decreases by the sum of + * `recipients`' amounts) and sfAssetsTotal (changes by `valueDelta`, same + * sign convention as addVaultAssets — typically an increase, since + * disbursing a loan recognizes accrued interest into sfAssetsTotal even as + * cash leaves the Vault), then transfers the Vault's asset from the Vault's + * pseudo-account to each of `recipients`, via accountSendMulti. + * + * Unlike removeVaultAssets, this is not a removal — the Vault's receivables + * grow to match the cash that leaves sfAssetsAvailable, so there is no + * "final" edge case to handle here. + * + * Recipients must already be able to hold the Vault's asset (e.g. via + * addEmptyHolding and requireAuth performed by the caller beforehand); this + * helper does not create holdings or check authorization. + * + * sfAssetsAvailable is decreased by an STAmount built from the sum of the + * recipients' Numbers, so for a very large recipient list whose sum exceeds + * STAmount's ~16 significant digits, this could round differently than + * summing the underlying Numbers directly. Not a concern for the current + * caller (LoanSet, two recipients). + * + * @param view The ledger view to apply changes to. + * @param vault The vault SLE. Must not be null. + * @param recipients The accounts and amounts to transfer from the Vault's + * pseudo-account. Must contain more than one entry. + * @param valueDelta The amount to add to sfAssetsTotal (same convention as + * addVaultAssets). May be negative, and may differ from + * the sum of `recipients`' amounts. + * @param j Journal for logging. + * + * @return TER from accountSendMulti. + */ +[[nodiscard]] TER +moveVaultAssets( + ApplyView& view, + SLE::ref vault, + MultiplePaymentDestinations const& recipients, + STAmount const& valueDelta, + beast::Journal j); + } // namespace xrpl diff --git a/include/xrpl/protocol/STAmount.h b/include/xrpl/protocol/STAmount.h index cc80481582..c17b74f113 100644 --- a/include/xrpl/protocol/STAmount.h +++ b/include/xrpl/protocol/STAmount.h @@ -772,6 +772,13 @@ roundToAsset( return roundToScale(ret, scale); } +[[nodiscard]] inline bool +isRounded(Asset const& asset, Number const& value, std::int32_t scale) +{ + return roundToAsset(asset, value, scale, Number::RoundingMode::Downward) == + roundToAsset(asset, value, scale, Number::RoundingMode::Upward); +} + //------------------------------------------------------------------------------ inline bool diff --git a/src/libxrpl/ledger/helpers/LendingHelpers.cpp b/src/libxrpl/ledger/helpers/LendingHelpers.cpp index 89b03a03a7..fd4cda58b9 100644 --- a/src/libxrpl/ledger/helpers/LendingHelpers.cpp +++ b/src/libxrpl/ledger/helpers/LendingHelpers.cpp @@ -120,17 +120,6 @@ loanPeriodicRate(TenthBips32 interestRate, std::uint32_t paymentInterval) return tenthBipsOfValue(Number(paymentInterval), interestRate) / kSecondsInYear; } -/* Checks if a value is already rounded to the specified scale. - * Returns true if rounding down and rounding up produce the same result, - * indicating no further precision exists beyond the scale. - */ -bool -isRounded(Asset const& asset, Number const& value, std::int32_t scale) -{ - return roundToAsset(asset, value, scale, Number::RoundingMode::Downward) == - roundToAsset(asset, value, scale, Number::RoundingMode::Upward); -} - namespace accrual { AccountingDeltas diff --git a/src/libxrpl/ledger/helpers/VaultHelpers.cpp b/src/libxrpl/ledger/helpers/VaultHelpers.cpp index 78f64d2077..c66c06f24d 100644 --- a/src/libxrpl/ledger/helpers/VaultHelpers.cpp +++ b/src/libxrpl/ledger/helpers/VaultHelpers.cpp @@ -1,9 +1,16 @@ #include +#include #include +#include +#include #include +#include #include +#include +#include #include +#include #include #include // IWYU pragma: keep #include @@ -11,6 +18,8 @@ #include #include #include // IWYU pragma: keep +#include +#include #include #include @@ -157,4 +166,193 @@ getVaultVersion(SLE::const_ref vault) return static_cast(version); } +[[nodiscard]] int +getVaultScale(SLE::const_ref vault) +{ + if (!vault) + return Number::kMinExponent - 1; // LCOV_EXCL_LINE + return scale(vault->at(sfAssetsTotal), vault->at(sfAsset)); +} + +[[nodiscard]] TER +addVaultAssets( + ApplyView& view, + SLE::ref vault, + AccountID const& sender, + STAmount const& amount, + STAmount const& valueDelta, + beast::Journal j) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::addVaultAssets : valid Vault sle"); + + Asset const asset = vault->at(sfAsset); + XRPL_ASSERT(amount.asset() == asset, "xrpl::addVaultAssets : amount matches vault asset"); + XRPL_ASSERT( + valueDelta.asset() == asset, "xrpl::addVaultAssets : valueDelta matches vault asset"); + + XRPL_ASSERT(amount >= beast::kZero, "xrpl::addVaultAssets : amount is non-negative"); + + // Callers are responsible for rounding amount/valueDelta to whatever + // scale their own accounting requires; this helper does not re-round. + // valueDelta and amount are independent (e.g. a loan default written off + // entirely by the vault, with no first-loss capital cover, has a nonzero + // (and possibly negative) valueDelta but a zero amount; late/regular loan + // payments can also carry a small negative valueDelta from untracked + // interest rounding corrections), so both fields are always updated even + // when there is nothing to transfer. + vault->at(sfAssetsTotal) += valueDelta; + vault->at(sfAssetsAvailable) += amount; + view.update(vault); + + if (auto const ter = + accountSend(view, sender, vault->at(sfAccount), amount, j, {}, WaiveTransferFee::Yes); + !isTesSuccess(ter)) + return ter; + + return tesSUCCESS; +} + +namespace { + +// Applies a full-removal mutation to the Vault's ledger fields: both +// callers (clawbackVaultAssets and removeVaultAssets) apply `amount` to +// sfAssetsTotal and sfAssetsAvailable equally (unlike +// addVaultAssets/moveVaultAssets, a full removal always shrinks both fields +// by the same amount). On a final removal, both fields are hard-reset to +// exactly zero rather than computed via subtraction: see FinalRemoval's +// doc comment for why an arithmetic subtraction cannot be trusted to land +// on exactly zero here. +void +applyRemoveVaultAssets( + ApplyView& view, + SLE::ref vault, + STAmount const& amount, + FinalRemoval finalRemoval) +{ + if (finalRemoval == FinalRemoval::Yes) + { + vault->at(sfAssetsTotal) = 0; + vault->at(sfAssetsAvailable) = 0; + } + else + { + vault->at(sfAssetsTotal) -= amount; + vault->at(sfAssetsAvailable) -= amount; + } + view.update(vault); +} + +} // namespace + +[[nodiscard]] TER +clawbackVaultAssets( + ApplyView& view, + SLE::ref vault, + AccountID const& recipient, + STAmount const& amount, + beast::Journal j) +{ + XRPL_ASSERT( + vault && vault->getType() == ltVAULT, "xrpl::clawbackVaultAssets : valid Vault sle"); + + Asset const asset = vault->at(sfAsset); + XRPL_ASSERT(amount.asset() == asset, "xrpl::clawbackVaultAssets : amount matches vault asset"); + XRPL_ASSERT(amount > beast::kZero, "xrpl::clawbackVaultAssets : amount is positive"); + + if (amount > *vault->at(sfAssetsAvailable)) + return tefINTERNAL; + + applyRemoveVaultAssets(view, vault, amount, FinalRemoval::No); + + if (auto const ter = accountSend( + view, vault->at(sfAccount), recipient, amount, j, {}, WaiveTransferFee::Yes); + !isTesSuccess(ter)) + return ter; + + // Sanity check + if (accountHolds( + view, + vault->at(sfAccount), + asset, + FreezeHandling::IgnoreFreeze, + AuthHandling::IgnoreAuth, + j) < beast::kZero) + { + // LCOV_EXCL_START + JLOG(j.error()) << "clawbackVaultAssets: negative balance of vault assets."; + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + return tesSUCCESS; +} + +[[nodiscard]] TER +removeVaultAssets( + ApplyViewContext ctx, + SLE::ref vault, + AccountID const& senderAcct, + AccountID const& dstAcct, + XRPAmount priorBalance, + STAmount const& amount, + beast::Journal j, + FinalRemoval finalRemoval) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::removeVaultAssets : valid Vault sle"); + + Asset const asset = vault->at(sfAsset); + XRPL_ASSERT(amount.asset() == asset, "xrpl::removeVaultAssets : amount matches vault asset"); + XRPL_ASSERT(amount >= beast::kZero, "xrpl::removeVaultAssets : amount is non-negative"); + + applyRemoveVaultAssets(ctx.view, vault, amount, finalRemoval); + + if (amount == beast::kZero) + return tesSUCCESS; + + return doWithdraw(ctx, senderAcct, dstAcct, vault->at(sfAccount), priorBalance, amount, j); +} + +[[nodiscard]] TER +moveVaultAssets( + ApplyView& view, + SLE::ref vault, + MultiplePaymentDestinations const& recipients, + STAmount const& valueDelta, + beast::Journal j) +{ + XRPL_ASSERT(vault && vault->getType() == ltVAULT, "xrpl::moveVaultAssets : valid Vault sle"); + XRPL_ASSERT(recipients.size() > 1, "xrpl::moveVaultAssets : multiple recipients provided"); + + Asset const asset = vault->at(sfAsset); + XRPL_ASSERT( + valueDelta.asset() == asset, "xrpl::moveVaultAssets : valueDelta matches vault asset"); + XRPL_ASSERT( + valueDelta == beast::kZero || getVaultVersion(vault) == VaultVersion::Legacy, + "xrpl::moveVaultAssets : nonzero valueDelta requires Legacy vault version"); + + Number amountTotal{}; + for (auto const& [recipient, recipientAmount] : recipients) + { + XRPL_ASSERT( + recipientAmount >= beast::kZero, + "xrpl::moveVaultAssets : recipientAmount is non-negative"); + amountTotal += recipientAmount; + } + STAmount const amount{asset, amountTotal}; + + // valueDelta follows addVaultAssets's convention (added to sfAssetsTotal): + // disbursing a loan typically increases sfAssetsTotal via accrued + // interest even as cash leaves sfAssetsAvailable. + + vault->at(sfAssetsTotal) += valueDelta; + vault->at(sfAssetsAvailable) -= amount; + view.update(vault); + + if (amount == beast::kZero) + return tesSUCCESS; + + return accountSendMulti( + view, vault->at(sfAccount), asset, recipients, j, WaiveTransferFee::Yes); +} + } // namespace xrpl