diff --git a/include/xrpl/ledger/View.h b/include/xrpl/ledger/View.h index 0893612bac..f7fd5b5a8c 100644 --- a/include/xrpl/ledger/View.h +++ b/include/xrpl/ledger/View.h @@ -78,6 +78,13 @@ isVaultPseudoAccountFrozen( MPTIssue const& mptShare, std::uint8_t depth); +[[nodiscard]] bool +isVaultPseudoAccountFrozen( + ReadView const& view, + AccountID const& account, + SLE const& issuanceSle, + std::uint8_t depth); + [[nodiscard]] bool isLPTokenFrozen( ReadView const& view, diff --git a/include/xrpl/ledger/helpers/MPTokenHelpers.h b/include/xrpl/ledger/helpers/MPTokenHelpers.h index 7babefd196..6d26cf3cbc 100644 --- a/include/xrpl/ledger/helpers/MPTokenHelpers.h +++ b/include/xrpl/ledger/helpers/MPTokenHelpers.h @@ -29,6 +29,9 @@ namespace xrpl { [[nodiscard]] bool isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue); +[[nodiscard]] bool +isGlobalFrozen(SLE const& issuanceSle); + /** * Returns true if @p account's MPToken for @p mptIssue carries the * individual-lock flag (lsfMPTLocked). @@ -40,9 +43,29 @@ isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue); * receive tokens — it combines isIndividualFrozen, isGlobalFrozen, and * isVaultPseudoAccountFrozen into a single complete check. */ + [[nodiscard]] bool isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue); +[[nodiscard]] bool +isIndividualFrozen(SLE const& mptSle); + +/** + * Returns true if @p account cannot send or receive tokens of @p mptIssue + * because a freeze applies. This is the complete check callers should use + * before moving MPT value: it combines @ref isGlobalFrozen (issuance-level + * lock), @ref isIndividualFrozen (per-holder lock bit), and the transitive + * vault pseudo-account check (if @p mptIssue is a vault share, the underlying + * asset is checked, and so on recursively up to @c maxAssetCheckDepth). + * + * The @c SLE overload takes an already-loaded ltMPTOKEN or ltMPTOKEN_ISSUANCE + * ledger entry; for ltMPTOKEN it can skip the per-holder individual-lock lookup. + * @ref isAnyFrozen answers the same question for a set of accounts and returns true + * if the freeze applies to any of them. + * + * @param depth Current recursion depth for the vault-share walk. Callers + * outside this module should leave it at the default. + */ [[nodiscard]] bool isFrozen( ReadView const& view, @@ -50,6 +73,18 @@ isFrozen( MPTIssue const& mptIssue, std::uint8_t depth = 0); +/** + * SLE overload: pass an already-loaded ltMPTOKEN (holder row) or + * ltMPTOKEN_ISSUANCE to reuse it for the freeze checks and avoid re-reading + * the same object. For an ltMPTOKEN, @p sle is used directly for the + * individual-lock check and the issuance is read once for global-freeze and + * vault-pseudo-account. For an ltMPTOKEN_ISSUANCE, @p sle is used directly + * for global-freeze and vault-pseudo-account, and the caller's holder row is + * read for the individual-lock check. + */ +[[nodiscard]] bool +isFrozen(ReadView const& view, AccountID const& account, SLE const& sle, std::uint8_t depth = 0); + [[nodiscard]] bool isAnyFrozen( ReadView const& view, diff --git a/src/libxrpl/ledger/View.cpp b/src/libxrpl/ledger/View.cpp index 0544771973..e01ae2e492 100644 --- a/src/libxrpl/ledger/View.cpp +++ b/src/libxrpl/ledger/View.cpp @@ -61,12 +61,10 @@ hasExpired( : view.parentCloseTime() > boundary; } -bool -isVaultPseudoAccountFrozen( - ReadView const& view, - AccountID const& account, - MPTIssue const& mptShare, - std::uint8_t depth) +namespace { + +std::optional +checkVaultPseudoAccountFrozenPreconditions(ReadView const& view, std::uint8_t depth) { if (!view.rules().enabled(featureSingleAssetVault)) return false; @@ -74,26 +72,37 @@ isVaultPseudoAccountFrozen( if (depth >= kMaxAssetCheckDepth) { // LCOV_EXCL_START - UNREACHABLE("xrpl::View::isVaultPseudoAccountFrozen : reached asset check depth"); + UNREACHABLE( + "xrpl::View::checkVaultPseudoAccountFrozenPreconditions : reached asset check depth"); return true; // LCOV_EXCL_STOP } - auto const mptIssuance = view.read(keylet::mptokenIssuance(mptShare.getMptID())); - if (mptIssuance == nullptr) - return false; // zero MPToken won't block deletion of MPTokenIssuance + return std::nullopt; +} - auto const issuer = mptIssuance->getAccountID(sfIssuer); +bool +isVaultPseudoAccountFrozenForIssuance( + ReadView const& view, + AccountID const& account, + SLE const& issuanceSle, + std::uint8_t depth) +{ + XRPL_ASSERT( + issuanceSle.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::isVaultPseudoAccountFrozenForIssuance : MPTokenIssuance SLE"); + + auto const issuer = issuanceSle.getAccountID(sfIssuer); // Post-fixCleanup3_2_0: vault shares carry sfReferenceHolding pointing // to the vault pseudo's MPToken or RippleState for the underlying. // Read it to derive the underlying asset and recurse, skipping the // issuer-account-then-vault chain. Pre-amendment shares (no field) // fall back to the chain lookup below. - if (mptIssuance->isFieldPresent(sfReferenceHolding)) + if (issuanceSle.isFieldPresent(sfReferenceHolding)) { auto const sleHolding = - view.read(keylet::unchecked(mptIssuance->getFieldH256(sfReferenceHolding))); + view.read(keylet::unchecked(issuanceSle.getFieldH256(sfReferenceHolding))); if (!sleHolding) { // LCOV_EXCL_START @@ -102,7 +111,7 @@ isVaultPseudoAccountFrozen( // LCOV_EXCL_STOP } return isAnyFrozen( - view, {issuer, account}, assetOfHolding(*mptIssuance, *sleHolding), depth + 1); + view, {issuer, account}, assetOfHolding(issuanceSle, *sleHolding), depth + 1); } auto const mptIssuer = view.read(keylet::account(issuer)); @@ -128,6 +137,38 @@ isVaultPseudoAccountFrozen( return isAnyFrozen(view, {issuer, account}, vault->at(sfAsset), depth + 1); } +} // namespace + +bool +isVaultPseudoAccountFrozen( + ReadView const& view, + AccountID const& account, + SLE const& issuanceSle, + std::uint8_t depth) +{ + if (auto const result = checkVaultPseudoAccountFrozenPreconditions(view, depth)) + return *result; + + return isVaultPseudoAccountFrozenForIssuance(view, account, issuanceSle, depth); +} + +bool +isVaultPseudoAccountFrozen( + ReadView const& view, + AccountID const& account, + MPTIssue const& mptShare, + std::uint8_t depth) +{ + if (auto const result = checkVaultPseudoAccountFrozenPreconditions(view, depth)) + return *result; + + auto const issuanceSle = view.read(keylet::mptokenIssuance(mptShare.getMptID())); + if (issuanceSle == nullptr) + return false; // zero MPToken won't block deletion of MPTokenIssuance + + return isVaultPseudoAccountFrozenForIssuance(view, account, *issuanceSle, depth); +} + bool isLPTokenFrozen( ReadView const& view, diff --git a/src/libxrpl/ledger/helpers/AMMHelpers.cpp b/src/libxrpl/ledger/helpers/AMMHelpers.cpp index df6d335085..fcad22d2d5 100644 --- a/src/libxrpl/ledger/helpers/AMMHelpers.cpp +++ b/src/libxrpl/ledger/helpers/AMMHelpers.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -633,7 +634,7 @@ ammAccountHolds(ReadView const& view, AccountID const& ammAccountID, Asset const return asset.visit( [&](MPTIssue const& issue) { if (auto const sle = view.read(keylet::mptoken(issue, ammAccountID)); - sle && !isFrozen(view, ammAccountID, issue)) + sle && !isFrozen(view, ammAccountID, *sle)) return STAmount{issue, (*sle)[sfMPTAmount]}; return STAmount{asset}; }, diff --git a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp index b239d0d3d1..73d5fdb1d5 100644 --- a/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/MPTokenHelpers.cpp @@ -42,18 +42,35 @@ bool isGlobalFrozen(ReadView const& view, MPTIssue const& mptIssue) { if (auto const sle = view.read(keylet::mptokenIssuance(mptIssue.getMptID()))) - return sle->isFlag(lsfMPTLocked); + return isGlobalFrozen(*sle); return false; } +bool +isGlobalFrozen(SLE const& issuanceSle) +{ + XRPL_ASSERT( + issuanceSle.getType() == ltMPTOKEN_ISSUANCE, "xrpl::isGlobalFrozen : MPTokenIssuance SLE"); + + return issuanceSle.isFlag(lsfMPTLocked); +} + bool isIndividualFrozen(ReadView const& view, AccountID const& account, MPTIssue const& mptIssue) { if (auto const sle = view.read(keylet::mptoken(mptIssue.getMptID(), account))) - return sle->isFlag(lsfMPTLocked); + return isIndividualFrozen(*sle); return false; } +bool +isIndividualFrozen(SLE const& mptSle) +{ + XRPL_ASSERT(mptSle.getType() == ltMPTOKEN, "xrpl::isIndividualFrozen : MPToken SLE"); + + return mptSle.isFlag(lsfMPTLocked); +} + bool isFrozen( ReadView const& view, @@ -65,6 +82,34 @@ isFrozen( isVaultPseudoAccountFrozen(view, account, mptIssue, depth); } +bool +isFrozen(ReadView const& view, AccountID const& account, SLE const& sle, std::uint8_t depth) +{ + XRPL_ASSERT( + sle.getType() == ltMPTOKEN || sle.getType() == ltMPTOKEN_ISSUANCE, + "xrpl::isFrozen : MPToken or MPTokenIssuance SLE"); + + if (sle.getType() == ltMPTOKEN) + { + XRPL_ASSERT(sle[sfAccount] == account, "xrpl::isFrozen : valid MPToken holder"); + + MPTID const mptID = sle[sfMPTokenIssuanceID]; + auto const issuanceSle = view.read(keylet::mptokenIssuance(mptID)); + + if ((issuanceSle && isGlobalFrozen(*issuanceSle)) || isIndividualFrozen(sle)) + return true; + + if (issuanceSle) + return isVaultPseudoAccountFrozen(view, account, *issuanceSle, depth); + + return isVaultPseudoAccountFrozen(view, account, MPTIssue{mptID}, depth); + } + + MPTIssue const mptIssue{sle[sfSequence], sle[sfIssuer]}; + return isGlobalFrozen(sle) || isIndividualFrozen(view, account, mptIssue) || + isVaultPseudoAccountFrozen(view, account, sle, depth); +} + [[nodiscard]] bool isAnyFrozen( ReadView const& view, @@ -72,7 +117,8 @@ isAnyFrozen( MPTIssue const& mptIssue, std::uint8_t depth) { - if (isGlobalFrozen(view, mptIssue)) + auto const issuanceSle = view.read(keylet::mptokenIssuance(mptIssue.getMptID())); + if (issuanceSle && isGlobalFrozen(*issuanceSle)) return true; for (auto const& account : accounts) @@ -81,9 +127,15 @@ isAnyFrozen( return true; } - return std::ranges::any_of(accounts, [&](auto const& account) { - return isVaultPseudoAccountFrozen(view, account, mptIssue, depth); - }); + // Pass the issuance SLE when we have it to avoid re-reading it per account; + // otherwise defer to the MPTIssue overload, which handles a missing issuance. + auto const anyVaultFrozen = [&](auto const& shareOrIssuance) { + return std::ranges::any_of(accounts, [&](auto const& account) { + return isVaultPseudoAccountFrozen(view, account, shareOrIssuance, depth); + }); + }; + + return issuanceSle ? anyVaultFrozen(*issuanceSle) : anyVaultFrozen(mptIssue); } Rate diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index 9e3452ccae..7ebfa64bcf 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -439,7 +439,7 @@ accountHolds( auto const sleMpt = view.read(keylet::mptoken(mptIssue.getMptID(), account)); if (!sleMpt || - (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, mptIssue))) + (zeroIfFrozen == FreezeHandling::ZeroIfFrozen && isFrozen(view, account, *sleMpt))) { amount.clear(mptIssue); } diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index 12ec078c82..d323718bd2 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -900,7 +900,7 @@ ValidMPTTransfer::finalize( // Check once: if any involved account is frozen, the whole issuance transfer is // considered frozen. Only need to check for frozen if there is a transfer of funds. if (!invalidTransfer && - (isFrozen(view, account, MPTIssue{mptID}) || + (isFrozen(view, account, *sleIssuance) || !isAuthorized(view, mptID, account, reqAuth))) { invalidTransfer = true; diff --git a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp index 212f9da075..0fe27fb3ba 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowCreate.cpp @@ -304,11 +304,11 @@ escrowCreatePreclaimHelper( return ter; // If the issuer has frozen the account, return tecLOCKED - if (isFrozen(ctx.view, account, mptIssue)) + if (isFrozen(ctx.view, account, *sleIssuance)) return tecLOCKED; // If the issuer has frozen the destination, return tecLOCKED - if (isFrozen(ctx.view, dest, mptIssue)) + if (isFrozen(ctx.view, dest, *sleIssuance)) return tecLOCKED; // If the mpt cannot be transferred, return tecNO_AUTH diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp index 32f4d9ec48..aa352d5e98 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp @@ -186,7 +186,7 @@ escrowFinishPreclaimHelper( return ter; // If the issuer has frozen the destination, return tecLOCKED - if (isFrozen(ctx.view, dest, mptIssue)) + if (isFrozen(ctx.view, dest, *sleIssuance)) return tecLOCKED; return tesSUCCESS; diff --git a/src/test/app/AMMMPT_test.cpp b/src/test/app/AMMMPT_test.cpp index 90a267f56f..bfd2d529b5 100644 --- a/src/test/app/AMMMPT_test.cpp +++ b/src/test/app/AMMMPT_test.cpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include #include #include @@ -7421,6 +7423,57 @@ private: } } + void + testDanglingAMMMPTokenFreezeCheck() + { + testcase("Dangling AMM MPToken freeze check"); + + using namespace jtx; + FeatureBitset const all{testableAmendments()}; + + Env env(*this, all); + + env.fund(XRP(1'000), gw_, alice_); + MPTTester usd({.env = env, .issuer = gw_}); + MPTTester const btc({.env = env, .issuer = gw_}); + + AMM amm(env, gw_, usd(10'000), btc(10'000)); + for (auto i = 0; i < kMaxDeletableAmmTrustLines + 10; ++i) + { + Account const a{std::to_string(i)}; + env.fund(XRP(1'000), a); + env(trust(a, STAmount{amm.lptIssue(), 10'000})); + env.close(); + } + + // With too many LP-token trust lines to delete in one pass, the AMM + // remains in an empty state with zero-balance MPToken objects. + amm.withdrawAll(gw_); + BEAST_EXPECT(amm.ammExists()); + BEAST_EXPECT(amm.expectBalances(usd(0), btc(0), IOUAmount{0})); + + auto const ammToken = env.le(keylet::mptoken(usd.issuanceID(), amm.ammAccount())); + if (!BEAST_EXPECT(ammToken)) + return; + BEAST_EXPECT((*ammToken)[sfMPTAmount] == 0); + + usd.destroy(); + BEAST_EXPECT(env.le(keylet::mptokenIssuance(usd.issuanceID())) == nullptr); + BEAST_EXPECT(!isFrozen(*env.current(), amm.ammAccount(), *ammToken)); + // A Payment cannot cross this empty AMM because BookStep skips AMMs + // with zero LPTokenBalance. Probe the same ZeroIfFrozen balance read + // used by AMM accounting. + auto const balance = accountHolds( + *env.current(), + amm.ammAccount(), + MPTIssue{usd.issuanceID()}, + FreezeHandling::ZeroIfFrozen, + AuthHandling::IgnoreAuth, + env.journal); + + BEAST_EXPECT(balance == usd(0)); + } + void run() override { @@ -7461,6 +7514,7 @@ private: testDepositIntegralOverflowMPT(all); testDepositIntegralOverflowMPT(all - fixCleanup3_4_0); testWithdrawIntegralNoOverflowMPT(); + testDanglingAMMMPTokenFreezeCheck(); } };