feat: Enforce MPT CanTransfer on AMM LPTokens transfers (#7418)

This commit is contained in:
Gregory Tsipenyuk
2026-08-17 20:58:46 +00:00
committed by GitHub
parent c49789086a
commit ca6121c5b3
5 changed files with 200 additions and 2 deletions

View File

@@ -85,6 +85,26 @@ isLPTokenFrozen(
Asset const& asset,
Asset const& asset2);
/**
* Check whether an AMM LPToken may be transferred between @p from and @p to.
*
* @p lpTokenIssuer is the issuer of the LPToken being moved. If it is not an
* AMM account the token is not an LPToken and the transfer is unconditionally
* permitted. Otherwise, for each MPT pool asset of that AMM, canTransfer() must
* permit the transfer (which exempts the MPT issuer). Non-MPT pool assets are
* always transferable by this check, so it is implicitly gated by
* featureMPTokensV2 (MPTs can only be AMM pool assets once V2 is enabled).
*
* @return tesSUCCESS if permitted, otherwise the canTransfer() failure code
* (e.g. tecNO_AUTH) of the first MPT pool asset that disallows it.
*/
[[nodiscard]] TER
canTransferLPToken(
ReadView const& view,
AccountID const& from,
AccountID const& to,
AccountID const& lpTokenIssuer);
// Return the list of enabled amendments
[[nodiscard]] std::set<uint256>
getEnabledAmendments(ReadView const& view);

View File

@@ -138,6 +138,33 @@ isLPTokenFrozen(
return isFrozen(view, account, asset) || isFrozen(view, account, asset2);
}
TER
canTransferLPToken(
ReadView const& view,
AccountID const& from,
AccountID const& to,
AccountID const& lpTokenIssuer)
{
// Only AMM-issued LPTokens are subject to this check. The LPToken's issuer
// is the AMM account; if it is not an AMM, this is not an LPToken.
auto const sleIssuer = view.read(keylet::account(lpTokenIssuer));
if (!sleIssuer || !sleIssuer->isFieldPresent(sfAMMID))
return tesSUCCESS;
auto const sleAmm = view.read(keylet::amm((*sleIssuer)[sfAMMID]));
if (!sleAmm)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const transferable = [&](Asset const& a) -> TER {
if (!a.holds<MPTIssue>())
return tesSUCCESS;
return canTransfer(view, a.get<MPTIssue>(), from, to);
};
if (auto const err = transferable((*sleAmm)[sfAsset]); !isTesSuccess(err))
return err;
return transferable((*sleAmm)[sfAsset2]);
}
bool
areCompatible(
ReadView const& validLedger,

View File

@@ -309,6 +309,15 @@ getLineIfUsable(
}
}
}
// An LPToken whose AMM pool contains an MPT that forbids transfers is not
// spendable. Issuer is the LPToken's AMM account; canTransferLPToken is
// a no-op for non-AMM issuers and non-MPT pool assets, so this is implicitly
// gated by featureMPTokensV2.
if (!isTesSuccess(canTransferLPToken(view, account, account, issuer)))
{
return nullptr;
}
}
return sle;

View File

@@ -4,6 +4,7 @@
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/PaymentSandbox.h>
#include <xrpl/ledger/View.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/RippleStateHelpers.h>
#include <xrpl/ledger/helpers/TokenHelpers.h>
@@ -845,8 +846,14 @@ DirectStepI<TDerived>::check(StrandContext const& ctx) const
// pure issue/redeem can't be frozen
if (!(ctx.isLast && ctx.isFirst))
{
auto const ter = checkFreeze(ctx.view, src_, dst_, currency_);
if (!isTesSuccess(ter))
if (auto const ter = checkFreeze(ctx.view, src_, dst_, currency_); !isTesSuccess(ter))
return ter;
// An LPToken redeemed against its AMM (dst_ is the LPToken issuer on
// this hop) cannot move if a pool asset is an MPT that forbids
// transfers between these accounts. A no-op unless dst_ is an AMM whose
// pool holds such an MPT (so it is implicitly gated by featureMPTokensV2).
if (auto const ter = canTransferLPToken(ctx.view, src_, dst_, dst_); !isTesSuccess(ter))
return ter;
}

View File

@@ -4,6 +4,7 @@
#include <test/jtx/TestHelpers.h>
#include <test/jtx/amount.h>
#include <test/jtx/check.h>
#include <test/jtx/mpt.h>
#include <test/jtx/offer.h>
#include <test/jtx/owners.h> // IWYU pragma: keep
#include <test/jtx/pay.h>
@@ -21,6 +22,8 @@
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <cstdint>
namespace xrpl::test {
class LPTokenTransfer_test : public jtx::AMMTest
@@ -433,6 +436,136 @@ class LPTokenTransfer_test : public jtx::AMMTest
}
}
void
testMPTCanTransferDirectStep(FeatureBitset features)
{
testcase("MPT CanTransfer DirectStep");
using namespace jtx;
// An MPT can only be an AMM pool asset once featureMPTokensV2 is
// enabled, so this behavior is only meaningful when V2 is present, and
// is independent of fixFrozenLPTokenTransfer.
if (!features[featureMPTokensV2])
return;
// gw issues an MPT used as one of the AMM pool assets. gw (the MPT
// issuer) seeds the pool and hands LP tokens to alice. Transferring LP
// tokens between two non-issuer holders is only permitted when the
// pool MPT allows transfers (lsfMPTCanTransfer); issuer-involving
// transfers are always permitted. The check fires on the redeem step
// against the AMM account via canTransferLPToken().
auto testLPTokenTransfer = [&](std::uint32_t mptFlags, bool poolXrpToBtc) {
Env env{*this, features};
env.fund(XRP(30'000), gw_, alice_, bob_);
env.close();
// gw is the MPT issuer, so it may seed the pool regardless of
// whether the MPT permits third-party transfers.
MPT const btc = MPTTester(
{.env = env, .issuer = gw_, .holders = {alice_}, .pay = 1'000, .flags = mptFlags});
auto const asset1 = poolXrpToBtc ? XRP(10'000) : btc(10'000);
auto const asset2 = poolXrpToBtc ? btc(10'000) : XRP(10'000);
AMM const amm(env, gw_, asset1, asset2);
auto const lpIssue = amm.lptIssue();
env.trust(STAmount{lpIssue, 100'000}, alice_);
env.trust(STAmount{lpIssue, 100'000}, bob_);
env.close();
// Issuer-involving LP token transfer is always allowed (gw is the
// pool MPT's issuer), even when the MPT lacks CanTransfer.
env(pay(gw_, alice_, STAmount{lpIssue, 1'000}));
env.close();
// Transfer between two non-issuer holders is allowed only if the
// pool MPT has CanTransfer set; otherwise the redeem step against
// the AMM account blocks it with tecNO_AUTH.
if ((mptFlags & tfMPTCanTransfer) != 0u)
{
env(pay(alice_, bob_, STAmount{lpIssue, 100}));
}
else
{
env(pay(alice_, bob_, STAmount{lpIssue, 100}), Ter(tecNO_AUTH));
}
env.close();
};
// Pool MPT without CanTransfer blocks third-party LP token transfers.
testLPTokenTransfer(tfMPTCanTrade, true);
testLPTokenTransfer(tfMPTCanTrade, false);
// Pool MPT with CanTransfer allows them.
testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, true);
testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, false);
}
void
testMPTCanTransferOffer(FeatureBitset features)
{
testcase("MPT CanTransfer Offer");
using namespace jtx;
if (!features[featureMPTokensV2])
return;
// Parity with frozen LP tokens for the order book: a non-transferable
// pool MPT makes the LP token un-spendable (canTransferLPToken zeroes
// the spendable balance in accountHolds, just as isLPTokenFrozen does),
// so an offer to sell it cannot be funded - the same tecUNFUNDED_OFFER
// outcome as freezing a pool asset (see testOfferCreation).
auto testLPTokenTransfer = [&](std::uint32_t mptFlags, bool poolXrpToBtc) {
Env env{*this, features};
env.fund(XRP(30'000), gw_, carol_);
env.close();
MPT const btc = MPTTester(
{.env = env, .issuer = gw_, .holders = {carol_}, .pay = 1'000, .flags = mptFlags});
auto const asset1 = poolXrpToBtc ? XRP(10'000) : btc(10'000);
auto const asset2 = poolXrpToBtc ? btc(10'000) : XRP(10'000);
AMM const amm(env, gw_, asset1, asset2);
auto const lpIssue = amm.lptIssue();
env.trust(STAmount{lpIssue, 100'000}, carol_);
env.close();
// gw (the pool MPT issuer) seeds carol_ with LP tokens; issuer
// involving transfers are always allowed.
env(pay(gw_, carol_, STAmount{lpIssue, 1'000}));
env.close();
// carol_ tries to create an offer to sell the LP token.
if ((mptFlags & tfMPTCanTransfer) != 0u)
{
env(offer(carol_, XRP(10), STAmount{lpIssue, 10}), Txflags(tfPassive));
env.close();
BEAST_EXPECT(expectOffers(env, carol_, 1));
}
else
{
// Non-transferable pool MPT => LP token un-spendable => the
// sell offer is unfunded, just as if a pool asset were frozen.
env(offer(carol_, XRP(10), STAmount{lpIssue, 10}),
Txflags(tfPassive),
Ter(tecUNFUNDED_OFFER));
env.close();
BEAST_EXPECT(expectOffers(env, carol_, 0));
}
};
// Pool MPT without CanTransfer: LP token sell offer is unfunded.
testLPTokenTransfer(tfMPTCanTrade, true);
testLPTokenTransfer(tfMPTCanTrade, false);
// Pool MPT with CanTransfer: LP token sell offer is created.
testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, true);
testLPTokenTransfer(tfMPTCanTrade | tfMPTCanTransfer, false);
}
public:
void
run() override
@@ -447,6 +580,8 @@ public:
testOfferCrossing(features);
testCheck(features);
testNFTOffers(features);
testMPTCanTransferDirectStep(features);
testMPTCanTransferOffer(features);
}
}
};