diff --git a/include/xrpl/tx/Transactor.h b/include/xrpl/tx/Transactor.h index 1440a5097f..ba55474734 100644 --- a/include/xrpl/tx/Transactor.h +++ b/include/xrpl/tx/Transactor.h @@ -263,10 +263,7 @@ protected: * to detect deletions. */ virtual void - visitInvariantEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr const& after) = 0; + visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) = 0; /** Check transaction-specific post-conditions after all entries have * been visited. diff --git a/include/xrpl/tx/invariants/VaultInvariant.h b/include/xrpl/tx/invariants/VaultInvariant.h index abc256c880..88f43e5ef9 100644 --- a/include/xrpl/tx/invariants/VaultInvariant.h +++ b/include/xrpl/tx/invariants/VaultInvariant.h @@ -1,18 +1,13 @@ #pragma once -#include -#include #include #include -#include -#include #include #include #include +#include -#include -#include -#include +#include namespace xrpl { @@ -37,127 +32,15 @@ namespace xrpl { */ class ValidVault { - static constexpr Number kZero{}; - - struct Vault final - { - uint256 key = beast::kZero; - Asset asset; - AccountID pseudoId; - AccountID owner; - uint192 shareMPTID = beast::kZero; - Number assetsTotal = 0; - Number assetsAvailable = 0; - Number assetsMaximum = 0; - Number lossUnrealized = 0; - - Vault static make(SLE const&); - }; - - struct Shares final - { - MPTIssue share; - std::uint64_t sharesTotal = 0; - std::uint64_t sharesMaximum = 0; - - Shares static make(SLE const&); - }; - public: - struct DeltaInfo final - { - 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); - }; - -private: - std::vector afterVault_; - std::vector afterMPTs_; - std::vector beforeVault_; - std::vector beforeMPTs_; - std::unordered_map 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 - 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 - 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 - 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 const& numbers); - void visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); bool finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); + +private: + VaultInvariantData data_; }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/vault/VaultCreate.h b/include/xrpl/tx/transactors/vault/VaultCreate.h index bbe80b49c1..190c726f99 100644 --- a/include/xrpl/tx/transactors/vault/VaultCreate.h +++ b/include/xrpl/tx/transactors/vault/VaultCreate.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace xrpl { @@ -41,6 +42,9 @@ public: XRPAmount fee, ReadView const& view, beast::Journal const& j) override; + +private: + VaultInvariantData invariantData_; }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/vault/VaultInvariantData.h b/include/xrpl/tx/transactors/vault/VaultInvariantData.h new file mode 100644 index 0000000000..f2c8528d42 --- /dev/null +++ b/include/xrpl/tx/transactors/vault/VaultInvariantData.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl { + +/** + * @brief Collects vault-related ledger-entry snapshots and balance-change + * deltas for use in per-transactor invariant checks. + * + * Each vault transactor that performs per-transactor invariant checking holds + * one instance of this class as a private member. During + * @c visitInvariantEntry the transactor calls @c visitEntry for every touched + * SLE; during @c finalizeInvariants it queries the collected data via the + * accessor and helper methods below. + */ +class VaultInvariantData +{ +public: + struct Vault final + { + uint256 key = beast::kZero; + Asset asset; + AccountID pseudoId; + AccountID owner; + uint192 shareMPTID = beast::kZero; + Number assetsTotal = 0; + Number assetsAvailable = 0; + Number assetsMaximum = 0; + Number lossUnrealized = 0; + + [[nodiscard]] static Vault + make(SLE const&); + }; + + struct Shares final + { + uint256 sleKey = beast::kZero; + MPTIssue share; + std::uint64_t sharesTotal = 0; + std::uint64_t sharesMaximum = 0; + + [[nodiscard]] static Shares + make(SLE const&); + }; + + struct DeltaInfo final + { + Number delta = kNumZero; + std::optional scale; + + [[nodiscard]] static DeltaInfo + makeDelta(Number const& before, Number const& after, Asset const& asset); + }; + + // Feed a single SLE change into the collected data. + void + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + + // Snapshot accessors ------------------------------------------------------- + + [[nodiscard]] std::vector const& + afterVault() const + { + return afterVault_; + } + + [[nodiscard]] std::vector const& + beforeVault() const + { + return beforeVault_; + } + + // Search afterMPTs_ for the share issuance matching @p afterVault. + [[nodiscard]] std::optional + resolveUpdatedShares(Vault const& afterVault) const; + + // Search beforeMPTs_ for the share issuance matching @p beforeVault. + [[nodiscard]] std::optional + resolveBeforeShares(Vault const& beforeVault) const; + + // Delta helpers ------------------------------------------------------------ + + [[nodiscard]] std::optional + deltaAssets(AccountID const& id) const; + + [[nodiscard]] std::optional + deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const; + + [[nodiscard]] std::optional + deltaShares(AccountID const& id) const; + + // Utilities ---------------------------------------------------------------- + + [[nodiscard]] static bool + isVaultEmpty(Vault const& vault); + + [[nodiscard]] static std::int32_t + computeCoarsestScale(std::vector const& numbers); + + [[nodiscard]] std::int32_t + computeVaultMinScale(DeltaInfo const& vaultDelta, Rules const& rules) const; + +private: + std::vector afterVault_; + std::vector afterMPTs_; + std::vector beforeVault_; + std::vector beforeMPTs_; + std::unordered_map deltas_; +}; + +} // namespace xrpl diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 4aa79279a1..6270cdbcd3 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -5,280 +5,34 @@ #include #include #include -#include #include #include -#include -#include -#include #include #include #include -#include // IWYU pragma: keep #include #include #include #include #include +#include #include -#include #include #include -#include -#include namespace xrpl { -ValidVault::Vault -ValidVault::Vault::make(SLE const& from) -{ - XRPL_ASSERT(from.getType() == ltVAULT, "ValidVault::Vault::make : from Vault object"); +static constexpr Number kZero{}; - ValidVault::Vault self; - self.key = from.key(); - self.asset = from.at(sfAsset); - self.pseudoId = from.getAccountID(sfAccount); - self.owner = from.at(sfOwner); - self.shareMPTID = from.getFieldH192(sfShareMPTID); - self.assetsTotal = from.at(sfAssetsTotal); - self.assetsAvailable = from.at(sfAssetsAvailable); - self.assetsMaximum = from.at(sfAssetsMaximum); - self.lossUnrealized = from.at(sfLossUnrealized); - return self; -} - -ValidVault::Shares -ValidVault::Shares::make(SLE const& from) -{ - XRPL_ASSERT( - from.getType() == ltMPTOKEN_ISSUANCE, - "ValidVault::Shares::make : from MPTokenIssuance object"); - - ValidVault::Shares self; - self.share = MPTIssue(makeMptID(from.getFieldU32(sfSequence), from.getAccountID(sfIssuer))); - self.sharesTotal = from.at(sfOutstandingAmount); - self.sharesMaximum = from[~sfMaximumAmount].value_or(kMaxMpTokenAmount); - return self; -} +using Vault = VaultInvariantData::Vault; +using Shares = VaultInvariantData::Shares; +using DeltaInfo = VaultInvariantData::DeltaInfo; void -ValidVault::visitEntry( - bool isDelete, - std::shared_ptr const& before, - std::shared_ptr 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 - // `isDelete` indicates whether an object is being deleted or modified. - XRPL_ASSERT( - after != nullptr && (before != nullptr || !isDelete), - "xrpl::ValidVault::visitEntry : some object is available"); - - // 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) - { - switch (after->getType()) - { - case ltVAULT: - 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::ValidVault::visitEntry", "scale initialized"); - balanceDelta.delta *= sign; - deltas_[key] = balanceDelta; - } -} - -std::optional -ValidVault::deltaAssets(AccountID const& id) const -{ - auto const& vaultAsset = afterVault_[0].asset; - auto const lookup = [&](uint256 const& key) -> std::optional { - auto const it = deltas_.find(key); - if (it == deltas_.end()) - return std::nullopt; - return it->second; - }; - - return std::visit( - [&](TIss const& issue) -> std::optional { - if constexpr (std::is_same_v) - { - 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) - { - return lookup(keylet::mptoken(issue.getMptID(), id).key); - } - }, - vaultAsset.value()); -} - -std::optional -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::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; -} - -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}); + data_.visitEntry(isDelete, before, after); } bool @@ -294,7 +48,7 @@ ValidVault::finalize( if (!isTesSuccess(ret)) return true; // Do not perform checks - if (afterVault_.empty() && beforeVault_.empty()) + if (data_.afterVault().empty() && data_.beforeVault().empty()) { if (hasPrivilege(tx, MustModifyVault)) { @@ -318,7 +72,7 @@ ValidVault::finalize( return !enforce; // Also not a vault operation } - if (beforeVault_.size() > 1 || afterVault_.size() > 1) + if (data_.beforeVault().size() > 1 || data_.afterVault().size() > 1) { JLOG(j.fatal()) << // "Invariant failed: vault operation updated more than single vault"; @@ -330,7 +84,7 @@ ValidVault::finalize( // We do special handling for ttVAULT_DELETE first, because it's the only // vault-modifying transaction without an "after" state of the vault - if (afterVault_.empty()) + if (data_.afterVault().empty()) { if (txnType != ttVAULT_DELETE) { @@ -345,19 +99,9 @@ ValidVault::finalize( // Note, if afterVault_ is empty then we know that beforeVault_ is not // empty, as enforced at the top of this function - auto const& beforeVault = beforeVault_[0]; + auto const& beforeVault = data_.beforeVault()[0]; - // At this moment we only know a vault is being deleted and there - // might be some MPTokenIssuance objects which are deleted in the - // same transaction. Find the one matching this vault. - auto const deletedShares = [&]() -> std::optional { - for (auto const& e : beforeMPTs_) - { - if (e.share.getMptID() == beforeVault.shareMPTID) - return e; - } - return std::nullopt; - }(); + auto const deletedShares = data_.resolveBeforeShares(beforeVault); if (!deletedShares) { @@ -398,34 +142,29 @@ ValidVault::finalize( } // Note, `afterVault_.empty()` is handled above - auto const& afterVault = afterVault_[0]; + auto const& afterVault = data_.afterVault()[0]; XRPL_ASSERT( - beforeVault_.empty() || beforeVault_[0].key == afterVault.key, + data_.beforeVault().empty() || data_.beforeVault()[0].key == afterVault.key, "xrpl::ValidVault::finalize : single vault operation"); auto const updatedShares = [&]() -> std::optional { - // At this moment we only know that a vault is being updated and there - // might be some MPTokenIssuance objects which are also updated in the - // same transaction. Find the one matching the shares to this vault. - // Note, we expect updatedMPTs collection to be extremely small. For - // such collections linear search is faster than lookup. - for (auto const& e : afterMPTs_) - { - if (e.share.getMptID() == afterVault.shareMPTID) - return e; - } + // Check the in-memory collection first (covers the common case where + // the issuance was touched by this transaction). + if (auto found = data_.resolveUpdatedShares(afterVault)) + return found; + // Fall back to reading from the view for transactions that do not + // modify the issuance object itself (e.g. VaultSet). auto const sleShares = view.read(keylet::mptIssuance(afterVault.shareMPTID)); - return sleShares ? std::optional(Shares::make(*sleShares)) : std::nullopt; }(); bool result = true; // Universal transaction checks - if (!beforeVault_.empty()) + if (!data_.beforeVault().empty()) { - auto const& beforeVault = beforeVault_[0]; + auto const& beforeVault = data_.beforeVault()[0]; if (afterVault.asset != beforeVault.asset || afterVault.pseudoId != beforeVault.pseudoId || afterVault.shareMPTID != beforeVault.shareMPTID) { @@ -498,7 +237,7 @@ ValidVault::finalize( // Thanks to this check we can simply do `assert(!beforeVault_.empty()` when // enforcing invariants on transaction types other than ttVAULT_CREATE - if (beforeVault_.empty() && txnType != ttVAULT_CREATE) + if (data_.beforeVault().empty() && txnType != ttVAULT_CREATE) { JLOG(j.fatal()) << // "Invariant failed: vault created by a wrong transaction type"; @@ -506,7 +245,8 @@ ValidVault::finalize( return !enforce; // That's all we can do here } - if (!beforeVault_.empty() && afterVault.lossUnrealized != beforeVault_[0].lossUnrealized && + if (!data_.beforeVault().empty() && + afterVault.lossUnrealized != data_.beforeVault()[0].lossUnrealized && txnType != ttLOAN_MANAGE && txnType != ttLOAN_PAY) { JLOG(j.fatal()) << // @@ -516,16 +256,9 @@ ValidVault::finalize( } auto const beforeShares = [&]() -> std::optional { - if (beforeVault_.empty()) + if (data_.beforeVault().empty()) return std::nullopt; - auto const& beforeVault = beforeVault_[0]; - - for (auto const& e : beforeMPTs_) - { - if (e.share.getMptID() == beforeVault.shareMPTID) - return e; - } - return std::nullopt; + return data_.resolveBeforeShares(data_.beforeVault()[0]); }(); if (!beforeShares && @@ -549,68 +282,19 @@ ValidVault::finalize( switch (txnType) { case ttVAULT_CREATE: { - bool result = true; - - if (!beforeVault_.empty()) - { - JLOG(j.fatal()) // - << "Invariant failed: create operation must not have " - "updated a vault"; - result = false; - } - - if (afterVault.assetsAvailable != kZero || afterVault.assetsTotal != kZero || - afterVault.lossUnrealized != kZero || updatedShares->sharesTotal != 0) - { - JLOG(j.fatal()) // - << "Invariant failed: created vault must be empty"; - result = false; - } - - if (afterVault.pseudoId != updatedShares->share.getIssuer()) - { - JLOG(j.fatal()) // - << "Invariant failed: shares issuer and vault " - "pseudo-account must be the same"; - result = false; - } - - auto const sleSharesIssuer = - view.read(keylet::account(updatedShares->share.getIssuer())); - if (!sleSharesIssuer) - { - JLOG(j.fatal()) // - << "Invariant failed: shares issuer must exist"; - return false; - } - - if (!isPseudoAccount(sleSharesIssuer)) - { - JLOG(j.fatal()) // - << "Invariant failed: shares issuer must be a " - "pseudo-account"; - result = false; - } - - if (auto const vaultId = (*sleSharesIssuer)[~sfVaultID]; - !vaultId || *vaultId != afterVault.key) - { - JLOG(j.fatal()) // - << "Invariant failed: shares issuer pseudo-account " - "must point back to the vault"; - result = false; - } - - return result; + // Per-transactor invariants for create are checked in + // VaultCreate::finalizeInvariants before this runs. + return true; } case ttVAULT_SET: { bool result = true; XRPL_ASSERT( - !beforeVault_.empty(), "xrpl::ValidVault::finalize : set updated a vault"); - auto const& beforeVault = beforeVault_[0]; + !data_.beforeVault().empty(), + "xrpl::ValidVault::finalize : set updated a vault"); + auto const& beforeVault = data_.beforeVault()[0]; - auto const vaultDeltaAssets = deltaAssets(afterVault.pseudoId); + auto const vaultDeltaAssets = data_.deltaAssets(afterVault.pseudoId); if (vaultDeltaAssets) { JLOG(j.fatal()) << // @@ -658,10 +342,11 @@ ValidVault::finalize( bool result = true; XRPL_ASSERT( - !beforeVault_.empty(), "xrpl::ValidVault::finalize : deposit updated a vault"); - auto const& beforeVault = beforeVault_[0]; + !data_.beforeVault().empty(), + "xrpl::ValidVault::finalize : deposit updated a vault"); + auto const& beforeVault = data_.beforeVault()[0]; - auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + auto const maybeVaultDeltaAssets = data_.deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { JLOG(j.fatal()) << // @@ -670,7 +355,8 @@ ValidVault::finalize( } // Get the posterior scale to round calculations to - auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + auto const minScale = + data_.computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); auto const vaultDeltaAssets = roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); @@ -701,15 +387,15 @@ ValidVault::finalize( if (!issuerDeposit) { - auto const maybeAccDeltaAssets = deltaAssetsTxAccount(tx, fee); + auto const maybeAccDeltaAssets = data_.deltaAssetsTxAccount(tx, fee); if (!maybeAccDeltaAssets) { JLOG(j.fatal()) << "Invariant failed: deposit must change depositor balance"; return false; } - auto const localMinScale = - std::max(minScale, computeCoarsestScale({*maybeAccDeltaAssets})); + auto const localMinScale = std::max( + minScale, VaultInvariantData::computeCoarsestScale({*maybeAccDeltaAssets})); auto const accountDeltaAssets = roundToAsset(vaultAsset, maybeAccDeltaAssets->delta, localMinScale); @@ -742,7 +428,7 @@ ValidVault::finalize( result = false; } - auto const maybeAccDeltaShares = deltaShares(tx[sfAccount]); + auto const maybeAccDeltaShares = data_.deltaShares(tx[sfAccount]); if (!maybeAccDeltaShares) { JLOG(j.fatal()) << "Invariant failed: deposit must change depositor shares"; @@ -756,7 +442,7 @@ ValidVault::finalize( result = false; } - auto const maybeVaultDeltaShares = deltaShares(afterVault.pseudoId); + auto const maybeVaultDeltaShares = data_.deltaShares(afterVault.pseudoId); if (!maybeVaultDeltaShares || maybeVaultDeltaShares->delta == kZero) { JLOG(j.fatal()) << "Invariant failed: deposit must change vault shares"; @@ -795,11 +481,11 @@ ValidVault::finalize( bool result = true; XRPL_ASSERT( - !beforeVault_.empty(), + !data_.beforeVault().empty(), "xrpl::ValidVault::finalize : withdrawal updated a vault"); - auto const& beforeVault = beforeVault_[0]; + auto const& beforeVault = data_.beforeVault()[0]; - auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + auto const maybeVaultDeltaAssets = data_.deltaAssets(afterVault.pseudoId); if (!maybeVaultDeltaAssets) { JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault balance"; @@ -807,7 +493,8 @@ ValidVault::finalize( } // Get the posterior scale to round calculations to - auto const minScale = computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + auto const minScale = + data_.computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); auto const vaultPseudoDeltaAssets = roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); @@ -829,11 +516,11 @@ ValidVault::finalize( if (!issuerWithdrawal) { - auto const maybeAccDelta = deltaAssetsTxAccount(tx, fee); + auto const maybeAccDelta = data_.deltaAssetsTxAccount(tx, fee); auto const maybeOtherAccDelta = [&]() -> std::optional { if (auto const destination = tx[~sfDestination]; destination && *destination != tx[sfAccount]) - return deltaAssets(*destination); + return data_.deltaAssets(*destination); return std::nullopt; }(); @@ -849,7 +536,8 @@ ValidVault::finalize( // the scale of destinationDelta can be coarser than // minScale, so we take that into account when rounding - auto const destinationScale = computeCoarsestScale({destinationDelta}); + auto const destinationScale = + VaultInvariantData::computeCoarsestScale({destinationDelta}); auto const localMinScale = std::max(minScale, destinationScale); auto const roundedDestinationDelta = @@ -900,7 +588,7 @@ ValidVault::finalize( } // We don't round shares, they are integral MPT - auto const accountDeltaShares = deltaShares(tx[sfAccount]); + auto const accountDeltaShares = data_.deltaShares(tx[sfAccount]); if (!accountDeltaShares) { JLOG(j.fatal()) << "Invariant failed: withdrawal must change depositor shares"; @@ -915,7 +603,7 @@ ValidVault::finalize( } // We don't round shares, they are integral MPT - auto const vaultDeltaShares = deltaShares(afterVault.pseudoId); + auto const vaultDeltaShares = data_.deltaShares(afterVault.pseudoId); if (!vaultDeltaShares || vaultDeltaShares->delta == kZero) { JLOG(j.fatal()) << "Invariant failed: withdrawal must change vault shares"; @@ -955,15 +643,17 @@ ValidVault::finalize( bool result = true; XRPL_ASSERT( - !beforeVault_.empty(), "xrpl::ValidVault::finalize : clawback updated a vault"); - auto const& beforeVault = beforeVault_[0]; + !data_.beforeVault().empty(), + "xrpl::ValidVault::finalize : clawback updated a vault"); + auto const& beforeVault = data_.beforeVault()[0]; if (vaultAsset.native() || vaultAsset.getIssuer() != tx[sfAccount]) { // The owner can use clawback to force-burn shares when the // vault is empty but there are outstanding shares if (!(beforeShares && beforeShares->sharesTotal > 0 && - isVaultEmpty(beforeVault) && beforeVault.owner == tx[sfAccount])) + VaultInvariantData::isVaultEmpty(beforeVault) && + beforeVault.owner == tx[sfAccount])) { JLOG(j.fatal()) << "Invariant failed: " << // "clawback may only be performed by the asset issuer, or by the vault " @@ -972,11 +662,11 @@ ValidVault::finalize( } } - auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); + auto const maybeVaultDeltaAssets = data_.deltaAssets(afterVault.pseudoId); if (maybeVaultDeltaAssets) { auto const minScale = - computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + data_.computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); auto const vaultDeltaAssets = roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); if (vaultDeltaAssets >= kZero) @@ -1005,7 +695,7 @@ ValidVault::finalize( result = false; } } - else if (!isVaultEmpty(beforeVault)) + else if (!VaultInvariantData::isVaultEmpty(beforeVault)) { JLOG(j.fatal()) << // "Invariant failed: clawback must change vault balance"; @@ -1013,7 +703,7 @@ ValidVault::finalize( } // We don't need to round shares, they are integral MPT - auto const maybeAccountDeltaShares = deltaShares(tx[sfHolder]); + auto const maybeAccountDeltaShares = data_.deltaShares(tx[sfHolder]); if (!maybeAccountDeltaShares) { JLOG(j.fatal()) << // @@ -1028,7 +718,7 @@ ValidVault::finalize( } // We don't need to round shares, they are integral MPT - auto const vaultDeltaShares = deltaShares(afterVault.pseudoId); + auto const vaultDeltaShares = data_.deltaShares(afterVault.pseudoId); if (!vaultDeltaShares || vaultDeltaShares->delta == kZero) { JLOG(j.fatal()) << // @@ -1072,25 +762,4 @@ ValidVault::finalize( return true; } -[[nodiscard]] ValidVault::DeltaInfo -ValidVault::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 -ValidVault::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::ValidVault::computeCoarsestScale", "scale set for destinationDelta"); - return max->scale.value_or(STAmount::kMaxOffset); -} - } // namespace xrpl diff --git a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp index 7490f44619..c1bfb7dd00 100644 --- a/src/libxrpl/tx/transactors/vault/VaultCreate.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultCreate.cpp @@ -1,9 +1,12 @@ #include +#include #include #include #include +#include #include +#include #include #include #include @@ -263,18 +266,106 @@ VaultCreate::doApply() } void -VaultCreate::visitInvariantEntry( - bool, - std::shared_ptr const&, - std::shared_ptr const&) +VaultCreate::visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { - // No transaction-specific invariants yet (future work). + invariantData_.visitEntry(isDelete, before, after); } bool -VaultCreate::finalizeInvariants(STTx const&, TER, XRPAmount, ReadView const&, beast::Journal const&) +VaultCreate::finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount, + ReadView const& view, + beast::Journal const& j) { - // No transaction-specific invariants yet (future work). + if (!isTesSuccess(result)) + return true; + + bool const enforce = view.rules().enabled(featureSingleAssetVault); + + auto const& afterVaults = invariantData_.afterVault(); + auto const& beforeVaults = invariantData_.beforeVault(); + + if (afterVaults.empty()) + { + JLOG(j.fatal()) << "Invariant failed: VaultCreate must create a vault"; + XRPL_ASSERT(enforce, "xrpl::VaultCreate::finalizeInvariants : vault created"); + return !enforce; + } + + auto const& afterVault = afterVaults[0]; + + bool ok = true; + + if (!beforeVaults.empty()) + { + JLOG(j.fatal()) << // + "Invariant failed: create operation must not have updated a vault"; + ok = false; + } + + auto const updatedShares = [&]() -> std::optional { + if (auto found = invariantData_.resolveUpdatedShares(afterVault)) + return found; + auto const sleShares = view.read(keylet::mptIssuance(afterVault.shareMPTID)); + if (!sleShares) + return std::nullopt; + return VaultInvariantData::Shares::make(*sleShares); + }(); + + if (!updatedShares) + { + JLOG(j.fatal()) << "Invariant failed: VaultCreate must create share issuance"; + XRPL_ASSERT(enforce, "xrpl::VaultCreate::finalizeInvariants : shares created"); + return !enforce; + } + + static constexpr Number kZero{}; + if (afterVault.assetsAvailable != kZero || afterVault.assetsTotal != kZero || + afterVault.lossUnrealized != kZero || updatedShares->sharesTotal != 0) + { + JLOG(j.fatal()) << "Invariant failed: created vault must be empty"; + ok = false; + } + + if (afterVault.pseudoId != updatedShares->share.getIssuer()) + { + JLOG(j.fatal()) // + << "Invariant failed: shares issuer and vault " + "pseudo-account must be the same"; + ok = false; + } + + auto const sleSharesIssuer = view.read(keylet::account(updatedShares->share.getIssuer())); + if (!sleSharesIssuer) + { + JLOG(j.fatal()) << "Invariant failed: shares issuer must exist"; + XRPL_ASSERT(enforce, "xrpl::VaultCreate::finalizeInvariants : shares issuer exists"); + return !enforce; + } + + if (!isPseudoAccount(sleSharesIssuer)) + { + JLOG(j.fatal()) // + << "Invariant failed: shares issuer must be a " + "pseudo-account"; + ok = false; + } + + if (auto const vaultId = (*sleSharesIssuer)[~sfVaultID]; !vaultId || *vaultId != afterVault.key) + { + JLOG(j.fatal()) // + << "Invariant failed: shares issuer pseudo-account " + "must point back to the vault"; + ok = false; + } + + if (!ok) + { + XRPL_ASSERT(enforce, "xrpl::VaultCreate::finalizeInvariants : vault invariants"); + return !enforce; + } return true; } diff --git a/src/libxrpl/tx/transactors/vault/VaultInvariantData.cpp b/src/libxrpl/tx/transactors/vault/VaultInvariantData.cpp new file mode 100644 index 0000000000..ef8054a0ac --- /dev/null +++ b/src/libxrpl/tx/transactors/vault/VaultInvariantData.cpp @@ -0,0 +1,329 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // IWYU pragma: keep +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +static constexpr Number kZero{}; + +VaultInvariantData::Vault +VaultInvariantData::Vault::make(SLE const& from) +{ + XRPL_ASSERT(from.getType() == ltVAULT, "VaultInvariantData::Vault::make : from Vault object"); + + VaultInvariantData::Vault self; + self.key = from.key(); + self.asset = from.at(sfAsset); + self.pseudoId = from.getAccountID(sfAccount); + self.owner = from.at(sfOwner); + self.shareMPTID = from.getFieldH192(sfShareMPTID); + self.assetsTotal = from.at(sfAssetsTotal); + self.assetsAvailable = from.at(sfAssetsAvailable); + self.assetsMaximum = from.at(sfAssetsMaximum); + self.lossUnrealized = from.at(sfLossUnrealized); + return self; +} + +VaultInvariantData::Shares +VaultInvariantData::Shares::make(SLE const& from) +{ + XRPL_ASSERT( + from.getType() == ltMPTOKEN_ISSUANCE, + "VaultInvariantData::Shares::make : from MPTokenIssuance object"); + + VaultInvariantData::Shares self; + self.sleKey = from.key(); + self.share = MPTIssue(makeMptID(from.getFieldU32(sfSequence), from.getAccountID(sfIssuer))); + self.sharesTotal = from.at(sfOutstandingAmount); + self.sharesMaximum = from[~sfMaximumAmount].value_or(kMaxMpTokenAmount); + return self; +} + +void +VaultInvariantData::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 + // `isDelete` indicates whether an object is being deleted or modified. + XRPL_ASSERT( + after != nullptr && (before != nullptr || !isDelete), + "xrpl::VaultInvariantData::visitEntry : some object is available"); + + // 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) + { + switch (after->getType()) + { + case ltVAULT: + 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 +VaultInvariantData::resolveUpdatedShares(Vault const& afterVault) const +{ + auto const targetKey = keylet::mptIssuance(afterVault.shareMPTID).key; + for (auto const& e : afterMPTs_) + { + if (e.sleKey == targetKey) + return e; + } + return std::nullopt; +} + +std::optional +VaultInvariantData::resolveBeforeShares(Vault const& beforeVault) const +{ + auto const targetKey = keylet::mptIssuance(beforeVault.shareMPTID).key; + for (auto const& e : beforeMPTs_) + { + if (e.sleKey == targetKey) + return e; + } + return std::nullopt; +} + +std::optional +VaultInvariantData::deltaAssets(AccountID const& id) const +{ + auto const& vaultAsset = afterVault_[0].asset; + auto const lookup = [&](uint256 const& key) -> std::optional { + auto const it = deltas_.find(key); + if (it == deltas_.end()) + return std::nullopt; + return it->second; + }; + + return std::visit( + [&](TIss const& issue) -> std::optional { + if constexpr (std::is_same_v) + { + 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) + { + return lookup(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; +} + +bool +VaultInvariantData::isVaultEmpty(Vault const& vault) +{ + return vault.assetsAvailable == 0 && vault.assetsTotal == 0; +} + +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}); +} + +[[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); +} + +} // namespace xrpl diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index c8a6e813de..a4047c5b7c 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -52,7 +52,7 @@ #include #include #include -#include +#include #include #include @@ -4764,12 +4764,12 @@ class Invariants_test : public beast::unit_test::Suite { std::string name; std::int32_t expectedMinScale; - std::vector values; + std::vector values; }; NumberMantissaScaleGuard const g{MantissaRange::MantissaScale::Large}; - auto makeDelta = [&vaultAsset](Number const& n) -> ValidVault::DeltaInfo { + auto makeDelta = [&vaultAsset](Number const& n) -> VaultInvariantData::DeltaInfo { return {.delta = n, .scale = scale(n, vaultAsset.raw())}; }; @@ -4811,7 +4811,7 @@ class Invariants_test : public beast::unit_test::Suite { testcase("vault computeCoarsestScale: " + tc.name); - auto const actualScale = ValidVault::computeCoarsestScale(tc.values); + auto const actualScale = VaultInvariantData::computeCoarsestScale(tc.values); BEAST_EXPECTS( actualScale == tc.expectedMinScale, @@ -4849,7 +4849,7 @@ class Invariants_test : public beast::unit_test::Suite { testcase("vault computeCoarsestScale: " + tc.name); - auto const actualScale = ValidVault::computeCoarsestScale(tc.values); + auto const actualScale = VaultInvariantData::computeCoarsestScale(tc.values); BEAST_EXPECTS( actualScale == tc.expectedMinScale,