From 457f8d04cee2298342d73230e203b07829fb9065 Mon Sep 17 00:00:00 2001 From: Vito <5780819+Tapanito@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:40:14 +0200 Subject: [PATCH] feat: Move VaultWithdraw invariants from ValidVault to VaultWithdraw --- .../xrpl/tx/invariants/VaultInvariantData.h | 105 ++++++- .../xrpl/tx/transactors/vault/VaultWithdraw.h | 4 + src/libxrpl/tx/invariants/VaultInvariant.cpp | 166 +---------- .../tx/invariants/VaultInvariantData.cpp | 261 +++++++++++++++++- .../tx/transactors/vault/VaultWithdraw.cpp | 200 +++++++++++++- 5 files changed, 559 insertions(+), 177 deletions(-) diff --git a/include/xrpl/tx/invariants/VaultInvariantData.h b/include/xrpl/tx/invariants/VaultInvariantData.h index e20456f74b..af3c160b2a 100644 --- a/include/xrpl/tx/invariants/VaultInvariantData.h +++ b/include/xrpl/tx/invariants/VaultInvariantData.h @@ -2,22 +2,26 @@ #include #include +#include #include #include #include #include +#include +#include #include +#include #include namespace xrpl { /** - * @brief Collects vault and share-issuance snapshots from ledger entry visits. + * @brief Collects vault and share-issuance snapshots from ledger entry visits, + * including full balance-delta tracking for per-transaction invariant checks. * - * Used by per-transaction invariant checks (e.g. VaultCreate) that need - * vault and MPTokenIssuance state without the full balance-delta tracking - * that ValidVault maintains. + * Used by per-transaction invariant checks (e.g. VaultCreate, VaultWithdraw) + * that need vault and MPTokenIssuance state with balance-delta tracking. */ class VaultInvariantData { @@ -48,6 +52,16 @@ public: make(SLE const&); }; + struct DeltaInfo + { + Number delta = kNumZero; + std::optional scale; + + // Compute the delta between two Numbers, taking the coarsest scale + [[nodiscard]] static DeltaInfo + makeDelta(Number const& before, Number const& after, Asset const& asset); + }; + void visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after); @@ -67,10 +81,93 @@ public: [[nodiscard]] std::optional findShares(uint192 const& mptID) const; + /** + * @brief Find a deleted (before-only) MPTokenIssuance whose mptID matches. + * + * Returns the Shares snapshot captured in beforeMPTs_ for the given mptID, + * or std::nullopt if none was deleted in this transaction. + */ + [[nodiscard]] std::optional + findDeletedShares(uint192 const& mptID) const; + + /** All MPTokenIssuance snapshots captured before modification or deletion. */ + [[nodiscard]] std::vector const& + beforeMPTIssuances() const + { + return beforeMPTs_; + } + + /** + * @brief Return the vault-asset balance-change delta for an account. + * + * Looks up the ledger-entry delta recorded during visitEntry for the + * account entry (XRP), trust line (IOU), or MPToken (MPT) that corresponds + * to the vault asset held by id. + * + * @param id Account whose asset delta is requested. + * @returns The delta, or std::nullopt if the entry was not touched. + */ + [[nodiscard]] std::optional + deltaAssets(AccountID const& id) const; + + /** + * @brief Return the vault-asset delta for the transaction's sending + * account, adjusted for the fee. + * + * Calls deltaAssets for 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 std::nullopt if the net delta is + * zero or the account entry was not touched. + */ + [[nodiscard]] std::optional + 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 MPTokenIssuance outstanding-amount + * delta is returned; for all other accounts the MPToken delta is returned. + * + * @param id Account whose share delta is requested. + * @returns The delta, or std::nullopt if the entry was not touched. + */ + [[nodiscard]] std::optional + deltaShares(AccountID const& id) const; + + /** + * @brief Compute the coarsest scale required to represent all numbers. + */ + [[nodiscard]] static std::int32_t + computeCoarsestScale(std::vector const& numbers); + + /** + * @brief Compute the minimum STAmount scale for rounding invariant + * calculations. + * + * Post-amendment (fixCleanup3_2_0) this is simply the posterior + * assetsTotal scale. Pre-amendment it is the coarsest scale across + * 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; + private: std::vector afterVault_; std::vector beforeVault_; std::vector afterMPTs_; + std::vector beforeMPTs_; + std::unordered_map deltas_; + + [[nodiscard]] std::optional + lookupDelta(uint256 const& key) const; }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/vault/VaultWithdraw.h b/include/xrpl/tx/transactors/vault/VaultWithdraw.h index 7bbe06187d..eda0952e28 100644 --- a/include/xrpl/tx/transactors/vault/VaultWithdraw.h +++ b/include/xrpl/tx/transactors/vault/VaultWithdraw.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace xrpl { @@ -32,6 +33,9 @@ public: XRPAmount fee, ReadView const& view, beast::Journal const& j) override; + +private: + VaultInvariantData data_; }; } // namespace xrpl diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index af1c722254..9e4e9ee3e7 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include @@ -525,8 +524,7 @@ ValidVault::finalize( }(); if (!beforeShares && - (tx.getTxnType() == ttVAULT_DEPOSIT || // - tx.getTxnType() == ttVAULT_WITHDRAW || // + (tx.getTxnType() == ttVAULT_DEPOSIT || // tx.getTxnType() == ttVAULT_CLAWBACK)) { JLOG(j.fatal()) << "Invariant failed: vault operation succeeded " @@ -545,10 +543,12 @@ ValidVault::finalize( switch (txnType) { case ttVAULT_CREATE: + case ttVAULT_WITHDRAW: case ttLOAN_SET: case ttLOAN_MANAGE: case ttLOAN_PAY: // Create-specific checks live in VaultCreate::finalizeInvariants. + // Withdraw-specific checks live in VaultWithdraw::finalizeInvariants. // Loan checks are TBD. return true; case ttVAULT_SET: { @@ -739,166 +739,6 @@ ValidVault::finalize( return result; } - case ttVAULT_WITHDRAW: { - bool result = true; - - XRPL_ASSERT( - !beforeVault_.empty(), - "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"; - return false; // That's all we can do - } - - // 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 >= kZero) - { - JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance"; - result = false; - } - - // Any payments (including withdrawal) going to the issuer - // do not change their balance, but destroy funds instead. - bool const issuerWithdrawal = [&]() -> bool { - if (vaultAsset.native()) - return false; - auto const destination = tx[~sfDestination].value_or(tx[sfAccount]); - return destination == vaultAsset.getIssuer(); - }(); - - if (!issuerWithdrawal) - { - auto const maybeAccDelta = deltaAssetsTxAccount(tx, fee); - auto const maybeOtherAccDelta = [&]() -> std::optional { - if (auto const destination = tx[~sfDestination]; - destination && *destination != tx[sfAccount]) - return deltaAssets(*destination); - return std::nullopt; - }(); - - if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value()) - { - JLOG(j.fatal()) << // - "Invariant failed: withdrawal must change one destination balance"; - return false; - } - - auto const destinationDelta = // - maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta; - - // the scale of destinationDelta can be coarser than - // minScale, so we take that into account when rounding - auto const destinationScale = computeCoarsestScale({destinationDelta}); - auto const localMinScale = std::max(minScale, destinationScale); - - auto const roundedDestinationDelta = - roundToAsset(vaultAsset, destinationDelta.delta, localMinScale); - - // 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"; - result = false; - } - - auto const localPseudoDeltaAssets = - roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale); - // 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"; - result = false; - } - } - - // 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"; - return false; - } - - if (accountDeltaShares->delta >= kZero) - { - JLOG(j.fatal()) - << "Invariant failed: withdrawal must decrease depositor shares"; - result = false; - } - - // We don't round shares, they are integral MPT - auto const vaultDeltaShares = deltaShares(afterVault.pseudoId); - if (!vaultDeltaShares || vaultDeltaShares->delta == kZero) - { - 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"; - result = false; - } - - auto const assetTotalDelta = roundToAsset( - vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); - // Note, vaultBalance is negative (see check above) - if (assetTotalDelta != vaultPseudoDeltaAssets) - { - JLOG(j.fatal()) - << "Invariant failed: withdrawal and assets outstanding must add up"; - result = false; - } - - auto const assetAvailableDelta = roundToAsset( - vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); - - if (assetAvailableDelta != vaultPseudoDeltaAssets) - { - JLOG(j.fatal()) - << "Invariant failed: withdrawal and assets available must add up"; - result = false; - } - - return result; - } case ttVAULT_CLAWBACK: { bool result = true; diff --git a/src/libxrpl/tx/invariants/VaultInvariantData.cpp b/src/libxrpl/tx/invariants/VaultInvariantData.cpp index ddc2807579..ea81502a38 100644 --- a/src/libxrpl/tx/invariants/VaultInvariantData.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariantData.cpp @@ -1,14 +1,32 @@ #include +#include #include +#include #include +#include #include #include +#include #include +#include +#include #include // IWYU pragma: keep +#include +#include + +#include +#include +#include +#include +#include namespace xrpl { +namespace { +constexpr Number kZero{}; +} // namespace + VaultInvariantData::Vault VaultInvariantData::Vault::make(SLE const& from) { @@ -48,8 +66,57 @@ VaultInvariantData::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ after != nullptr && (before != nullptr || !isDelete), "xrpl::VaultInvariantData::visitEntry : some object is available"); - if (before && before->getType() == ltVAULT) - beforeVault_.push_back(Vault::make(*before)); + // Number balanceDelta will capture the difference (delta) between "before" + // state (zero if created) and "after" state (zero if destroyed), and + // preserves value scale (exponent) to round values to the same scale during + // 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{.delta = kNumZero, .scale = std::nullopt}; + + std::int8_t sign = 0; + if (before) + { + switch (before->getType()) + { + case ltVAULT: + beforeVault_.push_back(Vault::make(*before)); + break; + case ltMPTOKEN_ISSUANCE: + // At this moment we have no way of telling if this object holds + // vault shares or something else. Save it for finalize. + beforeMPTs_.push_back(Shares::make(*before)); + balanceDelta.delta = + static_cast(before->getFieldU64(sfOutstandingAmount)); + // MPTs are ints, so the scale is always 0. + balanceDelta.scale = 0; + sign = 1; + break; + case ltMPTOKEN: + balanceDelta.delta = static_cast(before->getFieldU64(sfMPTAmount)); + // MPTs are ints, so the scale is always 0. + balanceDelta.scale = 0; + sign = -1; + break; + case ltACCOUNT_ROOT: + balanceDelta.delta = before->getFieldAmount(sfBalance); + // Account balance is XRP, which is an int, so the scale is + // always 0. + balanceDelta.scale = 0; + sign = -1; + break; + case ltRIPPLE_STATE: { + auto const amount = before->getFieldAmount(sfBalance); + balanceDelta.delta = amount; + // Trust Line balances are STAmounts, so we can use the exponent + // directly to get the scale. + balanceDelta.scale = amount.exponent(); + sign = -1; + break; + } + default:; + } + } if (!isDelete && after) { @@ -59,11 +126,58 @@ VaultInvariantData::visitEntry(bool isDelete, SLE::const_ref before, SLE::const_ afterVault_.push_back(Vault::make(*after)); break; case ltMPTOKEN_ISSUANCE: + // At this moment we have no way of telling if this object holds + // vault shares or something else. Save it for finalize. afterMPTs_.push_back(Shares::make(*after)); + balanceDelta.delta -= + Number(static_cast(after->getFieldU64(sfOutstandingAmount))); + // MPTs are ints, so the scale is always 0. + balanceDelta.scale = 0; + sign = 1; break; + case ltMPTOKEN: + balanceDelta.delta -= + Number(static_cast(after->getFieldU64(sfMPTAmount))); + // MPTs are ints, so the scale is always 0. + balanceDelta.scale = 0; + sign = -1; + break; + case ltACCOUNT_ROOT: + balanceDelta.delta -= Number(after->getFieldAmount(sfBalance)); + // Account balance is XRP, which is an int, so the scale is + // always 0. + balanceDelta.scale = 0; + sign = -1; + break; + case ltRIPPLE_STATE: { + auto const amount = after->getFieldAmount(sfBalance); + balanceDelta.delta -= Number(amount); + // Trust Line balances are STAmounts, so we can use the exponent + // directly to get the scale. + if (amount.exponent() > balanceDelta.scale) + { + balanceDelta.scale = amount.exponent(); + } + sign = -1; + break; + } default:; } } + + uint256 const key = (before ? before->key() : after->key()); + // Append to deltas if sign is non-zero, i.e. an object of an interesting + // type has been updated. A transaction may update an object even when + // its balance has not changed, e.g. transaction fee equals the amount + // transferred to the account. We intentionally do not compare balanceDelta + // against zero, to avoid missing such updates. + if (sign != 0) + { + XRPL_ASSERT_PARTS( + balanceDelta.scale, "xrpl::VaultInvariantData::visitEntry", "scale initialized"); + balanceDelta.delta *= sign; + deltas_[key] = balanceDelta; + } } std::optional @@ -77,4 +191,147 @@ VaultInvariantData::findShares(uint192 const& mptID) const return std::nullopt; } +std::optional +VaultInvariantData::findDeletedShares(uint192 const& mptID) const +{ + for (auto const& s : beforeMPTs_) + { + if (s.share.getMptID() == mptID) + { + // Only return if it was deleted (not in afterMPTs_) + bool const inAfter = [&]() { + for (auto const& a : afterMPTs_) + { + if (a.share.getMptID() == mptID) + return true; + } + return false; + }(); + if (!inAfter) + return s; + } + } + return std::nullopt; +} + +std::optional +VaultInvariantData::lookupDelta(uint256 const& key) const +{ + auto const it = deltas_.find(key); + if (it == deltas_.end()) + return std::nullopt; + return it->second; +} + +std::optional +VaultInvariantData::deltaAssets(AccountID const& id) const +{ + auto const& vaultAsset = afterVault_[0].asset; + + return std::visit( + [&](TIss const& issue) -> std::optional { + if constexpr (std::is_same_v) + { + if (isXRP(issue)) + { + return lookupDelta(keylet::account(id).key); + } + auto result = lookupDelta(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) + { + return lookupDelta(keylet::mptoken(issue.getMptID(), id).key); + } + }, + vaultAsset.value()); +} + +std::optional +VaultInvariantData::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 +VaultInvariantData::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(it->second) : std::nullopt; +} + +[[nodiscard]] VaultInvariantData::DeltaInfo +VaultInvariantData::DeltaInfo::makeDelta( + Number const& before, + Number const& after, + Asset const& asset) +{ + return { + .delta = after - before, + .scale = std::max(xrpl::scale(after, asset), xrpl::scale(before, asset))}; +} + +[[nodiscard]] std::int32_t +VaultInvariantData::computeCoarsestScale(std::vector const& numbers) +{ + if (numbers.empty()) + return 0; + + 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::VaultInvariantData::computeCoarsestScale", + "scale set for destinationDelta"); + return max->scale.value_or(STAmount::kMaxOffset); +} + +std::int32_t +VaultInvariantData::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}); +} + } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp index 05dcfea506..cbb79a91b2 100644 --- a/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultWithdraw.cpp @@ -1,7 +1,9 @@ #include #include +#include #include +#include #include #include #include @@ -15,6 +17,7 @@ #include #include #include +#include #include #include // IWYU pragma: keep #include @@ -367,20 +370,201 @@ VaultWithdraw::doApply() } void -VaultWithdraw::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +VaultWithdraw::visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { - // No transaction-specific invariants yet (future work). + data_.visitEntry(isDelete, before, after); } bool VaultWithdraw::finalizeInvariants( - STTx const&, - TER, - XRPAmount, - ReadView const&, - beast::Journal const&) + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) { - // No transaction-specific invariants yet (future work). + static constexpr Number kZero{}; + + bool const enforce = view.rules().enabled(featureSingleAssetVault); + + if (!isTesSuccess(result)) + return true; + + auto const& afterVaults = data_.afterVaults(); + if (afterVaults.empty()) + return true; + + auto const& afterVault = afterVaults[0]; + auto const& vaultAsset = afterVault.asset; + + XRPL_ASSERT( + !data_.beforeVaults().empty(), + "xrpl::VaultWithdraw::finalizeInvariants : withdrawal updated a vault"); + auto const& beforeVault = data_.beforeVaults()[0]; + + auto const maybeVaultDeltaAssets = data_.deltaAssets(afterVault.pseudoId); + if (!maybeVaultDeltaAssets) + { + JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance"; + XRPL_ASSERT(enforce, "xrpl::VaultWithdraw::finalizeInvariants : withdrawal changed vault"); + return !enforce; + } + + // Get the posterior scale to round calculations to + auto const minScale = data_.computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + + auto const vaultPseudoDeltaAssets = + roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); + + bool result_ = true; + + if (vaultPseudoDeltaAssets >= kZero) + { + JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease vault balance"; + result_ = false; + } + + // Any payments (including withdrawal) going to the issuer + // do not change their balance, but destroy funds instead. + bool const issuerWithdrawal = [&]() -> bool { + if (vaultAsset.native()) + return false; + auto const destination = tx[~sfDestination].value_or(tx[sfAccount]); + return destination == vaultAsset.getIssuer(); + }(); + + if (!issuerWithdrawal) + { + auto const maybeAccDelta = data_.deltaAssetsTxAccount(tx, fee); + auto const maybeOtherAccDelta = [&]() -> std::optional { + if (auto const destination = tx[~sfDestination]; + destination && *destination != tx[sfAccount]) + { + return data_.deltaAssets(*destination); + } + return std::nullopt; + }(); + + if (maybeAccDelta.has_value() == maybeOtherAccDelta.has_value()) + { + JLOG(j.fatal()) << // + "Invariant failed: withdrawal must change one destination balance"; + XRPL_ASSERT( + enforce, + "xrpl::VaultWithdraw::finalizeInvariants : withdrawal changed one destination"); + return !enforce; + } + + auto const destinationDelta = // + maybeAccDelta ? *maybeAccDelta : *maybeOtherAccDelta; + + // the scale of destinationDelta can be coarser than + // minScale, so we take that into account when rounding + auto const destinationScale = VaultInvariantData::computeCoarsestScale({destinationDelta}); + auto const localMinScale = std::max(minScale, destinationScale); + + auto const roundedDestinationDelta = + roundToAsset(vaultAsset, destinationDelta.delta, localMinScale); + + // 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"; + result_ = false; + } + + auto const localPseudoDeltaAssets = + roundToAsset(vaultAsset, vaultPseudoDeltaAssets, localMinScale); + // 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"; + result_ = false; + } + } + + // We don't round shares, they are integral MPT + auto const accountDeltaShares = data_.deltaShares(tx[sfAccount]); + if (!accountDeltaShares) + { + JLOG(j.fatal()) << "Invariant failed: withdrawal must change depositor shares"; + XRPL_ASSERT( + enforce, + "xrpl::VaultWithdraw::finalizeInvariants : withdrawal changed depositor shares"); + return !enforce; + } + + if (accountDeltaShares->delta >= kZero) + { + JLOG(j.fatal()) << "Invariant failed: withdrawal must decrease depositor shares"; + result_ = false; + } + + // We don't round shares, they are integral MPT + auto const vaultDeltaShares = data_.deltaShares(afterVault.pseudoId); + if (!vaultDeltaShares || vaultDeltaShares->delta == kZero) + { + JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault shares"; + XRPL_ASSERT( + enforce, "xrpl::VaultWithdraw::finalizeInvariants : withdrawal changed vault shares"); + return !enforce; + } + + if (vaultDeltaShares->delta * -1 != accountDeltaShares->delta) + { + JLOG(j.fatal()) << "Invariant failed: " << // + "withdrawal must change depositor and vault shares by equal amount"; + result_ = false; + } + + auto const assetTotalDelta = + roundToAsset(vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + // Note, vaultPseudoDeltaAssets is negative (see check above) + if (assetTotalDelta != vaultPseudoDeltaAssets) + { + JLOG(j.fatal()) << "Invariant failed: withdrawal and assets outstanding must add up"; + result_ = false; + } + + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + + if (assetAvailableDelta != vaultPseudoDeltaAssets) + { + JLOG(j.fatal()) << "Invariant failed: withdrawal and assets available must add up"; + result_ = false; + } + + if (!result_) + { + XRPL_ASSERT(enforce, "xrpl::VaultWithdraw::finalizeInvariants : vault invariants"); + return !enforce; + } + return true; }