diff --git a/include/xrpl/tx/invariants/VaultInvariantData.h b/include/xrpl/tx/invariants/VaultInvariantData.h index e20456f74b..48f8f1374f 100644 --- a/include/xrpl/tx/invariants/VaultInvariantData.h +++ b/include/xrpl/tx/invariants/VaultInvariantData.h @@ -2,22 +2,28 @@ #include #include +#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. * - * 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, VaultClawback) + * that need vault, MPTokenIssuance state and balance-delta information. */ class VaultInvariantData { @@ -48,6 +54,19 @@ public: make(SLE const&); }; + struct DeltaInfo + { + Number delta = kNumZero; + std::optional scale; + + /** + * @brief 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 +86,92 @@ public: [[nodiscard]] std::optional findShares(uint192 const& mptID) const; + /** + * @brief Find a deleted MPTokenIssuance (beforeMPTs_) whose mptID matches. + * + * Returns the Shares snapshot captured before the entry was deleted, + * or nullopt if no matching entry was found. + */ + [[nodiscard]] std::optional + findDeletedShares(uint192 const& mptID) const; + + /** + * @brief Return the before-MPT issuances vector (for deleted entries). + */ + [[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 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 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 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 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_; }; } // namespace xrpl diff --git a/include/xrpl/tx/transactors/vault/VaultClawback.h b/include/xrpl/tx/transactors/vault/VaultClawback.h index b8032809ee..25e5e94154 100644 --- a/include/xrpl/tx/transactors/vault/VaultClawback.h +++ b/include/xrpl/tx/transactors/vault/VaultClawback.h @@ -1,6 +1,7 @@ #pragma once #include +#include namespace xrpl { @@ -34,6 +35,8 @@ public: beast::Journal const& j) override; private: + VaultInvariantData data_; + Expected, TER> assetsToClawback( SLE::ref vault, diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index af1c722254..9f6cad9158 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,9 +524,8 @@ ValidVault::finalize( }(); if (!beforeShares && - (tx.getTxnType() == ttVAULT_DEPOSIT || // - tx.getTxnType() == ttVAULT_WITHDRAW || // - tx.getTxnType() == ttVAULT_CLAWBACK)) + (tx.getTxnType() == ttVAULT_DEPOSIT || // + tx.getTxnType() == ttVAULT_WITHDRAW)) { JLOG(j.fatal()) << "Invariant failed: vault operation succeeded " "without updating shares"; @@ -545,10 +543,12 @@ ValidVault::finalize( switch (txnType) { case ttVAULT_CREATE: + case ttVAULT_CLAWBACK: case ttLOAN_SET: case ttLOAN_MANAGE: case ttLOAN_PAY: // Create-specific checks live in VaultCreate::finalizeInvariants. + // Clawback-specific checks live in VaultClawback::finalizeInvariants. // Loan checks are TBD. return true; case ttVAULT_SET: { @@ -899,101 +899,6 @@ ValidVault::finalize( return result; } - case ttVAULT_CLAWBACK: { - bool result = true; - - XRPL_ASSERT( - !beforeVault_.empty(), "xrpl::ValidVault::finalize : clawback updated a vault"); - auto const& beforeVault = 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])) - { - 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 - } - } - - auto const maybeVaultDeltaAssets = deltaAssets(afterVault.pseudoId); - if (maybeVaultDeltaAssets) - { - auto const minScale = - computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); - auto const vaultDeltaAssets = - roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); - if (vaultDeltaAssets >= kZero) - { - JLOG(j.fatal()) << "Invariant failed: clawback must decrease vault balance"; - result = false; - } - - auto const assetsTotalDelta = roundToAsset( - vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); - if (assetsTotalDelta != vaultDeltaAssets) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback and assets outstanding must add up"; - result = false; - } - - auto const assetAvailableDelta = roundToAsset( - vaultAsset, - afterVault.assetsAvailable - beforeVault.assetsAvailable, - minScale); - if (assetAvailableDelta != vaultDeltaAssets) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback and assets available must add up"; - result = false; - } - } - else if (!isVaultEmpty(beforeVault)) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback must change vault balance"; - return false; // That's all we can do - } - - // We don't need to round shares, they are integral MPT - auto const maybeAccountDeltaShares = deltaShares(tx[sfHolder]); - if (!maybeAccountDeltaShares) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback must change holder shares"; - return false; // That's all we can do - } - if (maybeAccountDeltaShares->delta >= kZero) - { - JLOG(j.fatal()) << // - "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 == kZero) - { - JLOG(j.fatal()) << // - "Invariant failed: clawback must change vault shares"; - return false; // That's all we can do - } - - if (vaultDeltaShares->delta * -1 != maybeAccountDeltaShares->delta) - { - JLOG(j.fatal()) << "Invariant failed: " << // - "clawback must change holder and vault shares by equal amount"; - result = false; - } - - return result; - } - default: // LCOV_EXCL_START UNREACHABLE("xrpl::ValidVault::finalize : unknown transaction type"); diff --git a/src/libxrpl/tx/invariants/VaultInvariantData.cpp b/src/libxrpl/tx/invariants/VaultInvariantData.cpp index ddc2807579..bfa6e0bdfa 100644 --- a/src/libxrpl/tx/invariants/VaultInvariantData.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariantData.cpp @@ -1,11 +1,25 @@ #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 { @@ -48,8 +62,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 +122,56 @@ 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 +185,129 @@ 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) + return s; + } + 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 +{ + static constexpr Number kZero{}; + 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 delta"); + 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/VaultClawback.cpp b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp index eb12905467..7e9a1b591a 100644 --- a/src/libxrpl/tx/transactors/vault/VaultClawback.cpp +++ b/src/libxrpl/tx/transactors/vault/VaultClawback.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include @@ -451,20 +452,168 @@ VaultClawback::doApply() } void -VaultClawback::visitInvariantEntry(bool, SLE::const_ref, SLE::const_ref) +VaultClawback::visitInvariantEntry(bool isDelete, SLE::const_ref before, SLE::const_ref after) { - // No transaction-specific invariants yet (future work). + data_.visitEntry(isDelete, before, after); } bool VaultClawback::finalizeInvariants( - STTx const&, - TER, + STTx const& tx, + TER result, XRPAmount, - ReadView const&, - beast::Journal const&) + 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; // Do not perform checks + + auto const& afterVaults = data_.afterVaults(); + if (afterVaults.empty()) + return true; + + auto const& afterVault = afterVaults[0]; + auto const& vaultAsset = afterVault.asset; + + auto const& beforeVaults = data_.beforeVaults(); + XRPL_ASSERT( + !beforeVaults.empty(), + "xrpl::VaultClawback::finalizeInvariants : clawback updated a vault"); + auto const& beforeVault = beforeVaults[0]; + + // Retrieve the before-shares for this vault (from beforeMPTs_). + auto const beforeShares = [&]() -> std::optional { + for (auto const& e : data_.beforeMPTIssuances()) + { + if (e.share.getMptID() == beforeVault.shareMPTID) + return e; + } + return std::nullopt; + }(); + + auto const isVaultEmpty = [](VaultInvariantData::Vault const& v) -> bool { + return v.assetsAvailable == Number{} && v.assetsTotal == Number{}; + }; + + // Check 1: clawback is either by the asset issuer, OR by the vault owner + // on an empty vault (force-burn of lingering shares). + if (vaultAsset.native() || vaultAsset.getIssuer() != tx[sfAccount]) + { + if (!(beforeShares && beforeShares->sharesTotal > 0 && 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"; + XRPL_ASSERT( + enforce, + "xrpl::VaultClawback::finalizeInvariants : clawback issuer or empty vault owner"); + return !enforce; // That's all we can do + } + } + + bool checkResult = true; + + auto const maybeVaultDeltaAssets = data_.deltaAssets(afterVault.pseudoId); + if (maybeVaultDeltaAssets) + { + auto const minScale = data_.computeVaultMinScale(*maybeVaultDeltaAssets, view.rules()); + auto const vaultDeltaAssets = + roundToAsset(vaultAsset, maybeVaultDeltaAssets->delta, minScale); + + // Check 2: vault balance decreased. + if (vaultDeltaAssets >= kZero) + { + JLOG(j.fatal()) << "Invariant failed: clawback must decrease vault balance"; + checkResult = false; + } + + // Check 3: assetsTotal changed by vaultDelta. + auto const assetsTotalDelta = + roundToAsset(vaultAsset, afterVault.assetsTotal - beforeVault.assetsTotal, minScale); + if (assetsTotalDelta != vaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback and assets outstanding must add up"; + checkResult = false; + } + + // Check 4: assetsAvailable changed by vaultDelta. + auto const assetAvailableDelta = roundToAsset( + vaultAsset, afterVault.assetsAvailable - beforeVault.assetsAvailable, minScale); + if (assetAvailableDelta != vaultDeltaAssets) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback and assets available must add up"; + checkResult = false; + } + } + else if (!isVaultEmpty(beforeVault)) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback must change vault balance"; + XRPL_ASSERT( + enforce, "xrpl::VaultClawback::finalizeInvariants : clawback vault balance invariant"); + return !enforce; // That's all we can do + } + + // Check 5: holder shares (tx[sfHolder]) decreased. + // We don't need to round shares, they are integral MPT. + auto const maybeAccountDeltaShares = data_.deltaShares(tx[sfHolder]); + if (!maybeAccountDeltaShares) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback must change holder shares"; + XRPL_ASSERT( + enforce, "xrpl::VaultClawback::finalizeInvariants : clawback holder shares invariant"); + if (!checkResult) + { + XRPL_ASSERT( + enforce, "xrpl::VaultClawback::finalizeInvariants : vault clawback invariants"); + } + return !enforce; // That's all we can do + } + if (maybeAccountDeltaShares->delta >= kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback must decrease holder shares"; + checkResult = false; + } + + // Check 6: vault shares (outstanding) changed by same amount as holder decrease. + // We don't need to round shares, they are integral MPT. + auto const vaultDeltaShares = data_.deltaShares(afterVault.pseudoId); + if (!vaultDeltaShares || vaultDeltaShares->delta == kZero) + { + JLOG(j.fatal()) << // + "Invariant failed: clawback must change vault shares"; + XRPL_ASSERT( + enforce, "xrpl::VaultClawback::finalizeInvariants : clawback vault shares invariant"); + if (!checkResult) + { + XRPL_ASSERT( + enforce, "xrpl::VaultClawback::finalizeInvariants : vault clawback invariants"); + } + return !enforce; // That's all we can do + } + + if (vaultDeltaShares->delta * -1 != maybeAccountDeltaShares->delta) + { + JLOG(j.fatal()) << "Invariant failed: " // + << "clawback must change holder and vault shares by equal amount"; + checkResult = false; + } + + if (!checkResult) + { + XRPL_ASSERT(enforce, "xrpl::VaultClawback::finalizeInvariants : vault clawback invariants"); + return !enforce; + } + return true; }