From a91e8ddad7ad2b2fa7a67c8417b2d382f8fd1725 Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Mon, 6 Apr 2026 13:34:05 -0400 Subject: [PATCH] Add ValidMPTTransfer invariant. --- include/xrpl/tx/invariants/InvariantCheck.h | 3 +- include/xrpl/tx/invariants/MPTInvariant.h | 18 ++++ src/libxrpl/tx/invariants/MPTInvariant.cpp | 112 ++++++++++++++++++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index ad4c5e16c4..2d7c05e505 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -399,7 +399,8 @@ using InvariantChecks = std::tuple< ValidLoanBroker, ValidLoan, ValidVault, - ValidMPTPayment>; + ValidMPTPayment, + ValidMPTTransfer>; /** * @brief get a tuple of all invariant checks diff --git a/include/xrpl/tx/invariants/MPTInvariant.h b/include/xrpl/tx/invariants/MPTInvariant.h index dd064af396..bb3752eaa1 100644 --- a/include/xrpl/tx/invariants/MPTInvariant.h +++ b/include/xrpl/tx/invariants/MPTInvariant.h @@ -56,4 +56,22 @@ public: finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); }; +class ValidMPTTransfer +{ + struct Value + { + std::optional amtBefore; + std::optional amtAfter; + }; + // MPTID: {holder: Value} + hash_map> amount_; + +public: + void + visitEntry(bool, std::shared_ptr const&, std::shared_ptr const&); + + bool + finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&); +}; + } // namespace xrpl diff --git a/src/libxrpl/tx/invariants/MPTInvariant.cpp b/src/libxrpl/tx/invariants/MPTInvariant.cpp index de7bcb790a..28cb831a8c 100644 --- a/src/libxrpl/tx/invariants/MPTInvariant.cpp +++ b/src/libxrpl/tx/invariants/MPTInvariant.cpp @@ -369,4 +369,116 @@ ValidMPTPayment::finalize( return true; } +void +ValidMPTTransfer::visitEntry( + bool, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + // Record the before/after MPTAmount for each (issuanceID, account) pair + // so finalize() can determine whether a transfer actually occurred. + auto update = [&](SLE const& sle, bool isBefore) { + if (sle.getType() == ltMPTOKEN) + { + auto const issuanceID = sle[sfMPTokenIssuanceID]; + auto const account = sle[sfAccount]; + auto const amount = sle[sfMPTAmount]; + if (isBefore) + { + amount_[issuanceID][account].amtBefore = amount; + } + else + { + amount_[issuanceID][account].amtAfter = amount; + } + } + }; + + if (before) + update(*before, true); + + if (after) + update(*after, false); +} + +bool +ValidMPTTransfer::finalize( + STTx const& tx, + TER const, + XRPAmount const, + ReadView const& view, + beast::Journal const& j) +{ + // AMMClawback is called by the issuer, so freeze restrictions do not apply. + auto const txnType = tx.getTxnType(); + if (txnType == ttAMM_CLAWBACK) + return true; + + // DEX transactions (payments, check cash, offer creates) are subject to + // the MPTCanTrade flag in addition to the standard transfer rules. + // A payment is only DEX if it is a cross-currency payment. + auto const isDEX = [&tx, &txnType] { + if (txnType == ttPAYMENT) + { + // A payment is cross-currency (and thus DEX) only if SendMax is present + // and its asset differs from the destination asset. + auto const amount = tx[sfAmount]; + return tx[~sfSendMax].value_or(amount).asset() != amount.asset(); + } + return txnType == ttCHECK_CASH || txnType == ttOFFER_CREATE; + }(); + + // Only enforce once MPTokensV2 is enabled to preserve consensus with non-V2 nodes. + auto const enforce = !view.rules().enabled(featureMPTokensV2); + + for (auto const& [mptID, values] : amount_) + { + std::uint16_t senders = 0; + std::uint16_t receivers = 0; + bool frozen = false; + auto const sleIssuance = view.read(keylet::mptIssuance(mptID)); + if (!sleIssuance) + { + continue; + } + + auto const canTransfer = sleIssuance->isFlag(lsfMPTCanTransfer); + auto const canTrade = sleIssuance->isFlag(lsfMPTCanTrade); + + for (auto const& [account, value] : values) + { + // Check once: if any involved account is frozen, the whole + // issuance transfer is considered frozen. + if (!frozen && isFrozen(view, account, MPTIssue{mptID})) + { + frozen = true; + } + // Classify each account as a sender or receiver based on whether + // their MPTAmount decreased or increased. + if (value.amtBefore.has_value() && value.amtAfter.has_value() && + *value.amtBefore != *value.amtAfter) + { + if (*value.amtAfter > *value.amtBefore) + { + ++receivers; + } + else + { + ++senders; + } + } + } + // A transfer between holders has occurred (senders > 0 && receivers > 0). + // Fail if the issuance is frozen, does not permit transfers, or — for + // DEX transactions — does not permit trading. + if ((frozen || !canTransfer || (isDEX && !canTrade)) && senders > 0 && receivers > 0) + { + JLOG(j.fatal()) << "Invariant failed: invalid MPToken transfer between holders"; + return enforce; + } + } + + return true; +} + } // namespace xrpl