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.
This commit is contained in:
Vito
2026-08-10 12:40:10 +02:00
parent cb425647a4
commit 3c51e3dad1
5 changed files with 390 additions and 23 deletions

View File

@@ -7,6 +7,7 @@
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/Protocol.h>
@@ -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

View File

@@ -1,10 +1,15 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <optional>
@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,9 +1,16 @@
#include <xrpl/ledger/helpers/VaultHelpers.h>
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/LedgerFormats.h> // IWYU pragma: keep
#include <xrpl/protocol/Protocol.h>
@@ -11,6 +18,8 @@
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <cstdint>
#include <optional>
@@ -157,4 +166,193 @@ getVaultVersion(SLE::const_ref vault)
return static_cast<VaultVersion>(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