chore: Reset VaultInvariant to develop state before merge

VaultInvariant changes parked in /tmp/vault-invariant-changes.patch
for later re-evaluation.
This commit is contained in:
Vito
2026-06-08 11:19:05 +02:00
parent 68e4fbdf2b
commit db997ecad9
2 changed files with 334 additions and 243 deletions

View File

@@ -4,9 +4,11 @@
#include <xrpl/basics/base_uint.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/MPTIssue.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
#include <optional>
#include <unordered_map>
@@ -35,15 +37,15 @@ namespace xrpl {
*/
class ValidVault
{
Number static constexpr zero{};
static constexpr Number kZero{};
struct Vault final
{
uint256 key = beast::zero;
Asset asset = {};
AccountID pseudoId = {};
AccountID owner = {};
uint192 shareMPTID = beast::zero;
uint256 key = beast::kZero;
Asset asset;
AccountID pseudoId;
AccountID owner;
uint192 shareMPTID = beast::kZero;
Number assetsTotal = 0;
Number assetsAvailable = 0;
Number assetsMaximum = 0;
@@ -54,7 +56,7 @@ class ValidVault
struct Shares final
{
MPTIssue share = {};
MPTIssue share;
std::uint64_t sharesTotal = 0;
std::uint64_t sharesMaximum = 0;
@@ -64,27 +66,98 @@ class ValidVault
public:
struct DeltaInfo final
{
Number delta = numZero;
Number delta = kNumZero;
std::optional<int> scale;
// Compute the delta between two Numbers, taking the coarsest scale
[[nodiscard]] static DeltaInfo
makeDelta(Number const& before, Number const& after, Asset const& asset);
};
private:
std::vector<Vault> afterVault_ = {};
std::vector<Shares> afterMPTs_ = {};
std::vector<Vault> beforeVault_ = {};
std::vector<Shares> beforeMPTs_ = {};
std::unordered_map<uint256, DeltaInfo> deltas_ = {};
std::vector<Vault> afterVault_;
std::vector<Shares> afterMPTs_;
std::vector<Vault> beforeVault_;
std::vector<Shares> beforeMPTs_;
std::unordered_map<uint256, DeltaInfo> deltas_;
/**
* @brief Compute the minimum STAmount scale for rounding invariant
* calculations.
*
* Post-amendment (@c fixCleanup3_2_0) this is simply the posterior
* @c assetsTotal scale. Pre-amendment it is the coarsest scale across
* @p vaultDelta and both asset-field deltas.
*
* @param vaultDelta Delta of the vault's asset balance for this transaction.
* @param rules Active ledger rules (used to check the amendment).
* @returns The minimum scale to apply when rounding vault-related amounts.
*/
[[nodiscard]] std::int32_t
computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const;
/**
* @brief Return the vault-asset balance-change delta for an account.
*
* Looks up the ledger-entry delta recorded during @c visitEntry for the
* account entry (XRP), trust line (IOU), or MPToken (MPT) that corresponds
* to the vault asset held by @p id.
*
* @param id Account whose asset delta is requested.
* @returns The delta, or @c std::nullopt if the entry was not touched.
*/
[[nodiscard]] std::optional<DeltaInfo>
deltaAssets(AccountID const& id) const;
/**
* @brief Return the vault-asset delta for the transaction's sending
* account, adjusted for the fee.
*
* Calls @c deltaAssets for @c tx[sfAccount] and, for non-delegated XRP
* transactions, adds the consumed fee back so the invariant sees the net
* asset movement rather than the fee-reduced balance change.
*
* @param tx The transaction being applied.
* @param fee Fee charged by this transaction.
* @returns The fee-adjusted delta, or @c std::nullopt if the net delta is
* zero or the account entry was not touched.
*/
[[nodiscard]] std::optional<DeltaInfo>
deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const;
/**
* @brief Return the vault-share balance-change delta for an account.
*
* For the vault's pseudo-account the @c MPTokenIssuance outstanding-amount
* delta is returned; for all other accounts the @c MPToken delta is
* returned.
*
* @param id Account whose share delta is requested.
* @returns The delta, or @c std::nullopt if the entry was not touched.
*/
[[nodiscard]] std::optional<DeltaInfo>
deltaShares(AccountID const& id) const;
/**
* @brief Check whether a vault holds no assets.
*
* @param vault Snapshot of the vault to test.
* @returns @c true when both @c assetsAvailable and @c assetsTotal are
* zero.
*/
[[nodiscard]] static bool
isVaultEmpty(Vault const& vault);
public:
// Compute the coarsest scale required to represent all numbers
[[nodiscard]] static std::int32_t
computeCoarsestScale(std::vector<DeltaInfo> const& numbers);
void
visitEntry(bool, std::shared_ptr<SLE const> const&, std::shared_ptr<SLE const> const&);
visitEntry(bool, SLE::const_ref, SLE::const_ref);
bool
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&);
// Compute the coarsest scale required to represent all numbers
[[nodiscard]] static std::int32_t
computeMinScale(Asset const& asset, std::vector<DeltaInfo> const& numbers);
};
} // namespace xrpl

View File

@@ -1,18 +1,30 @@
#include <xrpl/tx/invariants/VaultInvariant.h>
//
#include <xrpl/basics/Log.h>
#include <xrpl/basics/Number.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Issue.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/Protocol.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
#include <xrpl/protocol/STNumber.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STNumber.h> // IWYU pragma: keep
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/invariants/InvariantCheckPrivilege.h>
#include <xrpl/tx/invariants/VaultInvariant.h>
#include <algorithm>
#include <cstdint>
#include <optional>
#include <variant>
#include <vector>
namespace xrpl {
@@ -44,15 +56,12 @@ ValidVault::Shares::make(SLE const& from)
ValidVault::Shares self;
self.share = MPTIssue(makeMptID(from.getFieldU32(sfSequence), from.getAccountID(sfIssuer)));
self.sharesTotal = from.at(sfOutstandingAmount);
self.sharesMaximum = from[~sfMaximumAmount].value_or(maxMPTokenAmount);
self.sharesMaximum = from[~sfMaximumAmount].value_or(kMaxMpTokenAmount);
return self;
}
void
ValidVault::visitEntry(
bool isDelete,
std::shared_ptr<SLE const> const& before,
std::shared_ptr<SLE const> const& after)
ValidVault::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after)
{
// If `before` is empty, this means an object is being created, in which
// case `isDelete` must be false. Otherwise `before` and `after` are set and
@@ -67,7 +76,7 @@ ValidVault::visitEntry(
// validation. It is used to validate that the change in account
// balances matches the change in vault balances, stored to deltas_ at the
// end of this function.
DeltaInfo balanceDelta{numZero, std::nullopt};
DeltaInfo balanceDelta{.delta = kNumZero, .scale = std::nullopt};
std::int8_t sign = 0;
if (before)
@@ -172,6 +181,101 @@ ValidVault::visitEntry(
}
}
std::optional<ValidVault::DeltaInfo>
ValidVault::deltaAssets(AccountID const& id) const
{
auto const& vaultAsset = afterVault_[0].asset;
auto const lookup = [&](uint256 const& key) -> std::optional<DeltaInfo> {
auto const it = deltas_.find(key);
if (it == deltas_.end())
return std::nullopt;
return it->second;
};
return std::visit(
[&]<typename TIss>(TIss const& issue) -> std::optional<DeltaInfo> {
if constexpr (std::is_same_v<TIss, Issue>)
{
if (isXRP(issue))
return lookup(keylet::account(id).key);
auto result = lookup(keylet::line(id, issue).key);
// Trust-line balance is stored from the low-account's perspective;
// negate if id is the high account so the delta is in id's terms.
if (result && id > issue.getIssuer())
result->delta = -result->delta;
return result;
}
else if constexpr (std::is_same_v<TIss, MPTIssue>)
{
return lookup(keylet::mptoken(issue.getMptID(), id).key);
}
},
vaultAsset.value());
}
std::optional<ValidVault::DeltaInfo>
ValidVault::deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const
{
auto const& vaultAsset = afterVault_[0].asset;
auto ret = deltaAssets(tx[sfAccount]);
if (!ret.has_value() || !vaultAsset.native())
return ret;
if (auto const delegate = tx[~sfDelegate]; delegate.has_value() && *delegate != tx[sfAccount])
return ret;
ret->delta += fee.drops();
if (ret->delta == kZero)
return std::nullopt;
return ret;
}
std::optional<ValidVault::DeltaInfo>
ValidVault::deltaShares(AccountID const& id) const
{
auto const& afterVault = afterVault_[0];
auto const it = [&]() {
if (id == afterVault.pseudoId)
return deltas_.find(keylet::mptIssuance(afterVault.shareMPTID).key);
return deltas_.find(keylet::mptoken(afterVault.shareMPTID, id).key);
}();
return it != deltas_.end() ? std::optional<DeltaInfo>(it->second) : std::nullopt;
}
bool
ValidVault::isVaultEmpty(Vault const& vault)
{
return vault.assetsAvailable == 0 && vault.assetsTotal == 0;
}
std::int32_t
ValidVault::computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const
{
// Returns the posterior `assetsTotal` scale.
//
// 1. Because STAmounts are normalized, `assetsTotal` (being >= `assetsAvailable`)
// safely represents the coarsest exponent needed for both fields.
//
// 2. The scale may decrease (withdraw/clawback) or increase (deposit). In both cases
// we ensure the vault is in a legitimate state in the post-transaction scale.
auto const& afterVault = afterVault_[0];
auto const& vaultAsset = afterVault.asset;
if (rules.enabled(fixCleanup3_2_0))
{
NumberRoundModeGuard const roundGuard(Number::RoundingMode::ToNearest);
return scale(afterVault.assetsTotal, vaultAsset);
}
auto const& beforeVault = beforeVault_[0];
auto const totalDelta =
DeltaInfo::makeDelta(beforeVault.assetsTotal, afterVault.assetsTotal, vaultAsset);
auto const availableDelta =
DeltaInfo::makeDelta(beforeVault.assetsAvailable, afterVault.assetsAvailable, vaultAsset);
return computeCoarsestScale({vaultDelta, totalDelta, availableDelta});
}
bool
ValidVault::finalize(
STTx const& tx,
@@ -187,7 +291,7 @@ ValidVault::finalize(
if (afterVault_.empty() && beforeVault_.empty())
{
if (hasPrivilege(tx, mustModifyVault))
if (hasPrivilege(tx, MustModifyVault))
{
JLOG(j.fatal()) << //
"Invariant failed: vault operation succeeded without modifying "
@@ -198,7 +302,7 @@ ValidVault::finalize(
return true; // Not a vault operation
}
if (!(hasPrivilege(tx, mustModifyVault) || hasPrivilege(tx, mayModifyVault)))
if (!(hasPrivilege(tx, MustModifyVault) || hasPrivilege(tx, MayModifyVault)))
{
JLOG(j.fatal()) << //
"Invariant failed: vault updated by a wrong transaction type";
@@ -265,13 +369,13 @@ ValidVault::finalize(
"shares outstanding";
result = false;
}
if (beforeVault.assetsTotal != zero)
if (beforeVault.assetsTotal != kZero)
{
JLOG(j.fatal()) << "Invariant failed: deleted vault must have no "
"assets outstanding";
result = false;
}
if (beforeVault.assetsAvailable != zero)
if (beforeVault.assetsAvailable != kZero)
{
JLOG(j.fatal()) << "Invariant failed: deleted vault must have no "
"assets available";
@@ -334,13 +438,13 @@ ValidVault::finalize(
if (updatedShares->sharesTotal == 0)
{
if (afterVault.assetsTotal != zero)
if (afterVault.assetsTotal != kZero)
{
JLOG(j.fatal()) << "Invariant failed: updated zero sized "
"vault must have no assets outstanding";
result = false;
}
if (afterVault.assetsAvailable != zero)
if (afterVault.assetsAvailable != kZero)
{
JLOG(j.fatal()) << "Invariant failed: updated zero sized "
"vault must have no assets available";
@@ -355,7 +459,7 @@ ValidVault::finalize(
result = false;
}
if (afterVault.assetsAvailable < zero)
if (afterVault.assetsAvailable < kZero)
{
JLOG(j.fatal()) << "Invariant failed: assets available must be positive";
result = false;
@@ -375,13 +479,13 @@ ValidVault::finalize(
result = false;
}
if (afterVault.assetsTotal < zero)
if (afterVault.assetsTotal < kZero)
{
JLOG(j.fatal()) << "Invariant failed: assets outstanding must be positive";
result = false;
}
if (afterVault.assetsMaximum < zero)
if (afterVault.assetsMaximum < kZero)
{
JLOG(j.fatal()) << "Invariant failed: assets maximum must be positive";
result = false;
@@ -431,61 +535,6 @@ ValidVault::finalize(
}
auto const& vaultAsset = afterVault.asset;
auto const deltaAssets = [&](AccountID const& id) -> std::optional<DeltaInfo> {
auto const get = //
[&](auto const& it, std::int8_t sign = 1) -> std::optional<DeltaInfo> {
if (it == deltas_.end())
return std::nullopt;
return DeltaInfo{it->second.delta * sign, it->second.scale};
};
return std::visit(
[&]<typename TIss>(TIss const& issue) {
if constexpr (std::is_same_v<TIss, Issue>)
{
if (isXRP(issue))
return get(deltas_.find(keylet::account(id).key));
return get(
deltas_.find(keylet::line(id, issue).key), id > issue.getIssuer() ? -1 : 1);
}
else if constexpr (std::is_same_v<TIss, MPTIssue>)
{
return get(deltas_.find(keylet::mptoken(issue.getMptID(), id).key));
}
},
vaultAsset.value());
};
auto const deltaAssetsTxAccount = [&]() -> std::optional<DeltaInfo> {
auto ret = deltaAssets(tx[sfAccount]);
// Nothing returned or not XRP transaction
if (!ret.has_value() || !vaultAsset.native())
return ret;
// Delegated transaction; no need to compensate for fees
if (auto const delegate = tx[~sfDelegate];
delegate.has_value() && *delegate != tx[sfAccount])
return ret;
ret->delta += fee.drops();
if (ret->delta == zero)
return std::nullopt;
return ret;
};
auto const deltaShares = [&](AccountID const& id) -> std::optional<DeltaInfo> {
auto const it = [&]() {
if (id == afterVault.pseudoId)
return deltas_.find(keylet::mptIssuance(afterVault.shareMPTID).key);
return deltas_.find(keylet::mptoken(afterVault.shareMPTID, id).key);
}();
return it != deltas_.end() ? std::optional<DeltaInfo>(it->second) : std::nullopt;
};
auto const vaultHoldsNoAssets = [&](Vault const& vault) {
return vault.assetsAvailable == 0 && vault.assetsTotal == 0;
};
// Technically this does not need to be a lambda, but it's more
// convenient thanks to early "return false"; the not-so-nice
@@ -505,8 +554,8 @@ ValidVault::finalize(
result = false;
}
if (afterVault.assetsAvailable != zero || afterVault.assetsTotal != zero ||
afterVault.lossUnrealized != zero || updatedShares->sharesTotal != 0)
if (afterVault.assetsAvailable != kZero || afterVault.assetsTotal != kZero ||
afterVault.lossUnrealized != kZero || updatedShares->sharesTotal != 0)
{
JLOG(j.fatal()) //
<< "Invariant failed: created vault must be empty";
@@ -572,7 +621,7 @@ ValidVault::finalize(
result = false;
}
if (afterVault.assetsMaximum > zero &&
if (afterVault.assetsMaximum > kZero &&
afterVault.assetsTotal > afterVault.assetsMaximum)
{
JLOG(j.fatal()) << //
@@ -615,24 +664,8 @@ ValidVault::finalize(
return false; // That's all we can do
}
// Get the coarsest scale to round calculations to
DeltaInfo totalDelta{
afterVault.assetsTotal - beforeVault.assetsTotal,
std::max(
scale(afterVault.assetsTotal, vaultAsset),
scale(beforeVault.assetsTotal, vaultAsset))};
DeltaInfo availableDelta{
afterVault.assetsAvailable - beforeVault.assetsAvailable,
std::max(
scale(afterVault.assetsAvailable, vaultAsset),
scale(beforeVault.assetsAvailable, vaultAsset))};
auto const minScale = computeMinScale(
vaultAsset,
{
*maybeVaultDeltaAssets,
totalDelta,
availableDelta,
});
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
auto const vaultDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
@@ -646,7 +679,7 @@ ValidVault::finalize(
result = false;
}
if (vaultDeltaAssets <= zero)
if (vaultDeltaAssets <= kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must increase vault balance";
@@ -663,81 +696,74 @@ ValidVault::finalize(
if (!issuerDeposit)
{
auto const maybeAccDeltaAssets = deltaAssetsTxAccount();
auto const maybeAccDeltaAssets = deltaAssetsTxAccount(tx, fee);
if (!maybeAccDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor "
"balance";
JLOG(j.fatal())
<< "Invariant failed: deposit must change depositor balance";
return false;
}
auto const localMinScale =
std::max(minScale, computeMinScale(vaultAsset, {*maybeAccDeltaAssets}));
std::max(minScale, computeCoarsestScale({*maybeAccDeltaAssets}));
auto const accountDeltaAssets =
roundToAsset(vaultAsset, maybeAccDeltaAssets->delta, localMinScale);
auto const localVaultDeltaAssets =
roundToAsset(vaultAsset, vaultDeltaAssets, localMinScale);
if (accountDeltaAssets >= zero)
// For IOUs, if the deposit amount is not-representable at depositor trustline
// scale deposit amount could round to zero, giving depositor shares for no
// assets. Unlike withdrawal, we do not allow that.
if (accountDeltaAssets >= kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must decrease depositor "
"balance";
JLOG(j.fatal())
<< "Invariant failed: deposit must decrease depositor balance";
result = false;
}
if (localVaultDeltaAssets * -1 != accountDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change vault and "
"depositor balance by equal amount";
JLOG(j.fatal()) << "Invariant failed: " << //
"deposit must change vault and depositor balance by equal amount";
result = false;
}
}
if (afterVault.assetsMaximum > zero &&
if (afterVault.assetsMaximum > kZero &&
afterVault.assetsTotal > afterVault.assetsMaximum)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit assets outstanding must not "
"exceed assets maximum";
JLOG(j.fatal()) << "Invariant failed: " << //
"deposit assets outstanding must not exceed assets maximum";
result = false;
}
auto const maybeAccDeltaShares = deltaShares(tx[sfAccount]);
if (!maybeAccDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor "
"shares";
JLOG(j.fatal()) << "Invariant failed: deposit must change depositor shares";
return false; // That's all we can do
}
// We don't need to round shares, they are integral MPT
// We don't round shares, they are integral MPT
auto const& accountDeltaShares = *maybeAccDeltaShares;
if (accountDeltaShares.delta <= zero)
if (accountDeltaShares.delta <= kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must increase depositor "
"shares";
JLOG(j.fatal()) << "Invariant failed: deposit must increase depositor shares";
result = false;
}
auto const maybeVaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!maybeVaultDeltaShares || maybeVaultDeltaShares->delta == zero)
if (!maybeVaultDeltaShares || maybeVaultDeltaShares->delta == kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change vault shares";
JLOG(j.fatal()) << "Invariant failed: deposit must change vault shares";
return false; // That's all we can do
}
// We don't need to round shares, they are integral MPT
// We don't round shares, they are integral MPT
auto const& vaultDeltaShares = *maybeVaultDeltaShares;
if (vaultDeltaShares.delta * -1 != accountDeltaShares.delta)
{
JLOG(j.fatal()) << //
"Invariant failed: deposit must change depositor and "
"vault shares by equal amount";
JLOG(j.fatal()) << "Invariant failed: " << //
"deposit must change depositor and vault shares by equal amount";
result = false;
}
@@ -745,8 +771,8 @@ ValidVault::finalize(
vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale);
if (assetTotalDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: deposit and assets "
"outstanding must add up";
JLOG(j.fatal())
<< "Invariant failed: deposit and assets outstanding must add up";
result = false;
}
@@ -754,8 +780,7 @@ ValidVault::finalize(
vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale);
if (assetAvailableDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: deposit and assets "
"available must add up";
JLOG(j.fatal()) << "Invariant failed: deposit and assets available must add up";
result = false;
}
@@ -766,40 +791,25 @@ ValidVault::finalize(
XRPL_ASSERT(
!beforeVault_.empty(),
"xrpl::ValidVault::finalize : withdrawal updated a "
"vault");
"xrpl::ValidVault::finalize : withdrawal updated a vault");
auto const& beforeVault = beforeVault_[0];
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (!maybeVaultDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must "
"change vault balance";
JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance";
return false; // That's all we can do
}
// Get the most coarse scale to round calculations to
auto const totalDelta = DeltaInfo{
afterVault.assetsTotal - beforeVault.assetsTotal,
std::max(
scale(afterVault.assetsTotal, vaultAsset),
scale(beforeVault.assetsTotal, vaultAsset))};
auto const availableDelta = DeltaInfo{
afterVault.assetsAvailable - beforeVault.assetsAvailable,
std::max(
scale(afterVault.assetsAvailable, vaultAsset),
scale(beforeVault.assetsAvailable, vaultAsset))};
auto const minScale = computeMinScale(
vaultAsset, {*maybeVaultDeltaAssets, totalDelta, availableDelta});
// Get the posterior scale to round calculations to
auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
auto const vaultPseudoDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
if (vaultPseudoDeltaAssets >= zero)
if (vaultPseudoDeltaAssets >= kZero)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal must "
"decrease vault balance";
JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance";
result = false;
}
@@ -814,7 +824,7 @@ ValidVault::finalize(
if (!issuerWithdrawal)
{
auto const maybeAccDelta = deltaAssetsTxAccount();
auto const maybeAccDelta = deltaAssetsTxAccount(tx, fee);
auto const maybeOtherAccDelta = [&]() -> std::optional<DeltaInfo> {
if (auto const destination = tx[~sfDestination];
destination && *destination != tx[sfAccount])
@@ -825,8 +835,7 @@ ValidVault::finalize(
if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value())
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change one "
"destination balance";
"Invariant failed: withdrawal must change one destination balance";
return false;
}
@@ -835,63 +844,83 @@ ValidVault::finalize(
// the scale of destinationDelta can be coarser than
// minScale, so we take that into account when rounding
auto const localMinScale =
std::max(minScale, computeMinScale(vaultAsset, {destinationDelta}));
auto const destinationScale = computeCoarsestScale({destinationDelta});
auto const localMinScale = std::max(minScale, destinationScale);
auto const roundedDestinationDelta =
roundToAsset(vaultAsset, destinationDelta.delta, localMinScale);
if (roundedDestinationDelta <= zero)
// Post-fixCleanup3_2_0: Tolerate zero-rounded destination deltas for IOUs only.
// If the receiver's trust line sits at a coarser scale, the inflow may
// safely round down to zero.
//
// XRP and MPT remain strict. Because they are integer-exact, a zero
// destination delta indicates a true accounting bug, not a rounding artifact.
bool const tolerateZeroDelta =
view.rules().enabled(fixCleanup3_2_0) && !vaultAsset.integral();
auto const invalidBalanceChange = tolerateZeroDelta
? roundedDestinationDelta < kZero
: roundedDestinationDelta <= kZero;
if (invalidBalanceChange)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must increase "
"destination balance";
"Invariant failed: withdrawal must increase destination balance";
result = false;
}
auto const localPseudoDeltaAssets =
roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale);
if (localPseudoDeltaAssets * -1 != roundedDestinationDelta)
// For IOU assets near a precision boundary the destination's STAmount
// exponent can shift, making part of the sent value unrepresentable at the
// receiver's new scale — that portion is irreversibly absorbed by the IOU
// rail. Tolerate the mismatch only when the destroyed amount (vault outflow
// minus destination inflow, in Number space) is itself sub-ULP at the
// destination's scale. Floor rounding is used so that values exactly at the
// step boundary are not mistakenly dismissed. Any representable discrepancy
// indicates a real accounting bug and must be caught.
auto const destroyedIsSubUlp = tolerateZeroDelta &&
roundToAsset(
vaultAsset,
maybeVaultDeltaAssets->delta * -1 - destinationDelta.delta,
destinationScale,
Number::RoundingMode::Downward) == kZero;
if (!destroyedIsSubUlp &&
localPseudoDeltaAssets * -1 != roundedDestinationDelta)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change vault "
"and destination balance by equal amount";
JLOG(j.fatal()) << "Invariant failed: " << //
"withdrawal must change vault and destination balance by equal "
"amount";
result = false;
}
}
// We don't need to round shares, they are integral MPT
// We don't round shares, they are integral MPT
auto const accountDeltaShares = deltaShares(tx[sfAccount]);
if (!accountDeltaShares)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change depositor "
"shares";
JLOG(j.fatal()) << "Invariant failed: withdrawal must change depositor shares";
return false;
}
if (accountDeltaShares->delta >= zero)
if (accountDeltaShares->delta >= kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must decrease depositor "
"shares";
JLOG(j.fatal())
<< "Invariant failed: withdrawal must decrease depositor shares";
result = false;
}
// We don't need to round shares, they are integral MPT
// We don't round shares, they are integral MPT
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || vaultDeltaShares->delta == zero)
if (!vaultDeltaShares || vaultDeltaShares->delta == kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change vault shares";
JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault shares";
return false; // That's all we can do
}
if (vaultDeltaShares->delta * -1 != accountDeltaShares->delta)
{
JLOG(j.fatal()) << //
"Invariant failed: withdrawal must change depositor "
"and vault shares by equal amount";
JLOG(j.fatal()) << "Invariant failed: " << //
"withdrawal must change depositor and vault shares by equal amount";
result = false;
}
@@ -900,8 +929,8 @@ ValidVault::finalize(
// Note, vaultBalance is negative (see check above)
if (assetTotalDelta != vaultPseudoDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal and "
"assets outstanding must add up";
JLOG(j.fatal())
<< "Invariant failed: withdrawal and assets outstanding must add up";
result = false;
}
@@ -910,8 +939,8 @@ ValidVault::finalize(
if (assetAvailableDelta != vaultPseudoDeltaAssets)
{
JLOG(j.fatal()) << "Invariant failed: withdrawal and "
"assets available must add up";
JLOG(j.fatal())
<< "Invariant failed: withdrawal and assets available must add up";
result = false;
}
@@ -929,12 +958,11 @@ ValidVault::finalize(
// The owner can use clawback to force-burn shares when the
// vault is empty but there are outstanding shares
if (!(beforeShares && beforeShares->sharesTotal > 0 &&
vaultHoldsNoAssets(beforeVault) && beforeVault.owner == tx[sfAccount]))
isVaultEmpty(beforeVault) && beforeVault.owner == tx[sfAccount]))
{
JLOG(j.fatal()) << //
"Invariant failed: clawback may only be performed "
"by the asset issuer, or by the vault owner of an "
"empty vault";
JLOG(j.fatal()) << "Invariant failed: " << //
"clawback may only be performed by the asset issuer, or by the vault "
"owner of an empty vault";
return false; // That's all we can do
}
}
@@ -942,25 +970,13 @@ ValidVault::finalize(
auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId);
if (maybeVaultDeltaAssets)
{
auto const totalDelta = DeltaInfo{
afterVault.assetsTotal - beforeVault.assetsTotal,
std::max(
scale(afterVault.assetsTotal, vaultAsset),
scale(beforeVault.assetsTotal, vaultAsset))};
auto const availableDelta = DeltaInfo{
afterVault.assetsAvailable - beforeVault.assetsAvailable,
std::max(
scale(afterVault.assetsAvailable, vaultAsset),
scale(beforeVault.assetsAvailable, vaultAsset))};
auto const minScale = computeMinScale(
vaultAsset, {*maybeVaultDeltaAssets, totalDelta, availableDelta});
auto const minScale =
computeVaultMinScale(*maybeVaultDeltaAssets, view.rules());
auto const vaultDeltaAssets =
roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale);
if (vaultDeltaAssets >= zero)
if (vaultDeltaAssets >= kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must decrease vault "
"balance";
JLOG(j.fatal()) << "Invariant failed: clawback must decrease vault balance";
result = false;
}
@@ -969,8 +985,7 @@ ValidVault::finalize(
if (assetsTotalDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback and assets outstanding "
"must add up";
"Invariant failed: clawback and assets outstanding must add up";
result = false;
}
@@ -981,12 +996,11 @@ ValidVault::finalize(
if (assetAvailableDelta != vaultDeltaAssets)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback and assets available "
"must add up";
"Invariant failed: clawback and assets available must add up";
result = false;
}
}
else if (!vaultHoldsNoAssets(beforeVault))
else if (!isVaultEmpty(beforeVault))
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must change vault balance";
@@ -1001,17 +1015,16 @@ ValidVault::finalize(
"Invariant failed: clawback must change holder shares";
return false; // That's all we can do
}
if (maybeAccountDeltaShares->delta >= zero)
if (maybeAccountDeltaShares->delta >= kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must decrease holder "
"shares";
"Invariant failed: clawback must decrease holder shares";
result = false;
}
// We don't need to round shares, they are integral MPT
auto const vaultDeltaShares = deltaShares(afterVault.pseudoId);
if (!vaultDeltaShares || vaultDeltaShares->delta == zero)
if (!vaultDeltaShares || vaultDeltaShares->delta == kZero)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must change vault shares";
@@ -1020,9 +1033,8 @@ ValidVault::finalize(
if (vaultDeltaShares->delta * -1 != maybeAccountDeltaShares->delta)
{
JLOG(j.fatal()) << //
"Invariant failed: clawback must change holder and "
"vault shares by equal amount";
JLOG(j.fatal()) << "Invariant failed: " << //
"clawback must change holder and vault shares by equal amount";
result = false;
}
@@ -1055,19 +1067,25 @@ ValidVault::finalize(
return true;
}
[[nodiscard]] std::int32_t
ValidVault::computeMinScale(Asset const& asset, std::vector<DeltaInfo> const& numbers)
[[nodiscard]] ValidVault::DeltaInfo
ValidVault::DeltaInfo::makeDelta(Number const& before, Number const& after, Asset const& asset)
{
if (numbers.size() == 0)
return {
.delta = after - before,
.scale = std::max(xrpl::scale(after, asset), xrpl::scale(before, asset))};
}
[[nodiscard]] std::int32_t
ValidVault::computeCoarsestScale(std::vector<DeltaInfo> const& numbers)
{
if (numbers.empty())
return 0;
auto const max =
std::max_element(numbers.begin(), numbers.end(), [](auto const& a, auto const& b) -> bool {
return a.scale < b.scale;
});
auto const max = std::ranges::max_element(
numbers, [](auto const& a, auto const& b) -> bool { return a.scale < b.scale; });
XRPL_ASSERT_PARTS(
max->scale, "xrpl::ValidVault::computeMinScale", "scale set for destinationDelta");
return max->scale.value_or(STAmount::cMaxOffset);
max->scale, "xrpl::ValidVault::computeCoarsestScale", "scale set for destinationDelta");
return max->scale.value_or(STAmount::kMaxOffset);
}
} // namespace xrpl