Add ValidMPTTransfer invariant.

This commit is contained in:
Gregory Tsipenyuk
2026-04-06 13:34:05 -04:00
parent 56c9d1d497
commit a91e8ddad7
3 changed files with 132 additions and 1 deletions

View File

@@ -399,7 +399,8 @@ using InvariantChecks = std::tuple<
ValidLoanBroker,
ValidLoan,
ValidVault,
ValidMPTPayment>;
ValidMPTPayment,
ValidMPTTransfer>;
/**
* @brief get a tuple of all invariant checks

View File

@@ -56,4 +56,22 @@ public:
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&);
};
class ValidMPTTransfer
{
struct Value
{
std::optional<std::uint64_t> amtBefore;
std::optional<std::uint64_t> amtAfter;
};
// MPTID: {holder: Value}
hash_map<uint192, hash_map<AccountID, Value>> amount_;
public:
void
visitEntry(bool, std::shared_ptr<SLE const> const&, std::shared_ptr<SLE const> const&);
bool
finalize(STTx const&, TER const, XRPAmount const, ReadView const&, beast::Journal const&);
};
} // namespace xrpl

View File

@@ -369,4 +369,116 @@ ValidMPTPayment::finalize(
return true;
}
void
ValidMPTTransfer::visitEntry(
bool,
std::shared_ptr<SLE const> const& before,
std::shared_ptr<SLE const> 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