Defer MPT creation until offer consume

This commit is contained in:
Gregory Tsipenyuk
2026-05-16 06:38:55 -04:00
parent 21cfba66fd
commit 01164a3ec0
2 changed files with 173 additions and 13 deletions

View File

@@ -740,18 +740,6 @@ BookStep<TIn, TOut, TDerived>::forEachOffer(
bool const isAssetInMPT = assetIn.holds<MPTIssue>();
auto const& owner = offer.owner();
if (isAssetInMPT)
{
// Create MPToken for the offer's owner. No need to check
// for the reserve since the offer is removed if it is consumed.
// Therefore, the owner count remains the same.
if (auto const err = checkCreateMPT(sb, assetIn.get<MPTIssue>(), owner, j_);
!isTesSuccess(err))
{
return true;
}
}
auto removeOffer = [&](std::string_view logMessage = {}) {
auto const key = offer.key();
if (!logMessage.empty())
@@ -773,7 +761,11 @@ BookStep<TIn, TOut, TDerived>::forEachOffer(
// Make sure offer owner has authorization to own Assets from issuer
// and MPT assets can be traded/transferred.
// An account can always own XRP or their own Assets.
if (!isTesSuccess(requireAuth(applyView, assetIn, owner)) || !checkMPTDEX(sb, owner))
// Missing MPTokens are allowed during offer discovery; they are
// created later if the offer is actually consumed.
auto const authType = isAssetInMPT ? AuthType::WeakAuth : AuthType::Legacy;
if (!isTesSuccess(requireAuth(applyView, assetIn, owner, authType)) ||
!checkMPTDEX(sb, owner))
{
// Offer owner not authorized to hold IOU/MPT from issuer.
// Remove this offer even if no crossing occurs.
@@ -921,6 +913,18 @@ BookStep<TIn, TOut, TDerived>::consumeOffer(
// The offer owner gets the ofrAmt. The difference between ofrAmt and
// stepAmt is a transfer fee that goes to book_.in.account
{
if constexpr (std::is_same_v<TIn, MPTAmount>)
{
// If the offer's TakerPays asset is an MPT, the offer owner must
// hold an MPToken to receive it. Create one here, after the
// consumption decision has been made, so the +1 to ownerCount is
// paired atomically with the -1 that follows when the offer SLE is
// deleted by BookTip::step().
if (auto const err = checkCreateMPT(sb, book_.in.get<MPTIssue>(), offer.owner(), j_);
!isTesSuccess(err))
Throw<FlowException>(err);
}
auto const dr = offer.send(
sb, book_.in.getIssuer(), offer.owner(), toSTAmount(ofrAmt.in, book_.in), j_);
if (!isTesSuccess(dr))

View File

@@ -741,6 +741,161 @@ struct FlowMPT_test : public beast::unit_test::Suite
return result;
}
void
testOfferOwnerMPTCreation(FeatureBitset features)
{
using namespace jtx;
Account const alice("alice");
Account const bob("bob");
Account const carol("carol");
Account const gw("gw");
{
testcase("Reserve-edge offer owner cannot create another object");
Env env(*this, features);
auto const baseFee = env.current()->fees().base;
auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
auto const xrpOffer = ownerIncrement - drops(1);
auto const bobStart = reserve(env, 2) - drops(1) + baseFee;
env.fund(XRP(10'000), alice, gw);
env.fund(bobStart, bob);
env.close();
MPTTester const usd({.env = env, .issuer = gw, .maxAmt = 10});
env(offer(bob, usd(1), xrpOffer));
env.close();
env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1));
// This mirrors the full-crossing setup below. Bob has enough XRP
// for the resting offer, but not enough to pay a fee and add
// another owner-count object while the offer remains on ledger.
env(check::create(bob, alice, drops(1)), Ter(tecINSUFFICIENT_RESERVE));
env.close();
env.require(Owners(bob, 1));
BEAST_EXPECT(offersOnAccount(env, bob).size() == 1);
}
{
testcase("Reserve-edge offer owner creates MPToken during consume");
Env env(*this, features);
auto const baseFee = env.current()->fees().base;
auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
auto const xrpOffer = ownerIncrement - drops(1);
auto const bobStart = reserve(env, 2) - drops(1) + baseFee;
env.fund(XRP(10'000), alice, carol, gw);
env.fund(bobStart, bob);
env.close();
MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
env(pay(gw, alice, usd(1)));
env(offer(bob, usd(1), xrpOffer));
env.close();
env.require(Balance(bob, reserve(env, 2) - drops(1)), Owners(bob, 1));
BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
auto const carolXRP = env.balance(carol);
// Bob has enough XRP for the resting offer but is close to
// reserve. The payment should not create Bob's USD MPToken until
// the offer is actually consumed, otherwise the temporary owner
// count increase can make the offer look underfunded during path
// execution.
env(pay(alice, carol, xrpOffer),
Path(~XRP),
Sendmax(usd(1)),
Txflags(tfNoRippleDirect));
env.close();
env.require(Balance(carol, carolXRP + xrpOffer));
env.require(Balance(bob, usd(1)));
env.require(Balance(bob, reserve(env, 1)), Owners(bob, 1));
BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
BEAST_EXPECT(offersOnAccount(env, bob).empty());
}
{
testcase("Partial offer owner creates MPToken during consume");
Env env(*this, features);
auto const baseFee = env.current()->fees().base;
auto const ownerIncrement = reserve(env, 1) - reserve(env, 0);
auto const bobStart = reserve(env, 3) + baseFee;
env.fund(XRP(10'000), alice, carol, gw);
env.fund(bobStart, bob);
env.close();
MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
env(pay(gw, alice, usd(1)));
env(offer(bob, usd(2), drops(2 * ownerIncrement)));
env.close();
env.require(Balance(bob, reserve(env, 3)), Owners(bob, 1));
BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
auto const carolXRP = env.balance(carol);
// Partial consumption leaves Bob's offer on ledger, so this setup
// gives Bob enough reserve for both the remaining offer and the
// newly created MPToken.
env(pay(alice, carol, drops(ownerIncrement)),
Path(~XRP),
Sendmax(usd(1)),
Txflags(tfNoRippleDirect));
env.close();
env.require(Balance(carol, carolXRP + drops(ownerIncrement)));
env.require(Balance(bob, usd(1)));
env.require(Balance(bob, reserve(env, 2)), Owners(bob, 2));
BEAST_EXPECT(env.le(keylet::mptoken(usd.issuanceID(), bob.id())));
BEAST_EXPECT(offersOnAccount(env, bob).size() == 1);
BEAST_EXPECT(isOffer(env, bob, usd(1), drops(ownerIncrement)));
}
{
testcase("Issuer-owned offer does not create issuer MPToken");
Env env(*this, features);
env.fund(XRP(10'000), alice, carol, gw);
env.close();
MPTTester const usd({.env = env, .issuer = gw, .holders = {alice}, .maxAmt = 10});
env(pay(gw, alice, usd(1)));
env(offer(gw, usd(1), drops(1'000)));
env.close();
BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id())));
auto const carolXRP = env.balance(carol);
// The issuer can own an offer that receives its own MPT without an
// MPToken. Consuming that offer should keep the issuer side
// tokenless.
env(pay(alice, carol, drops(1'000)),
Path(~XRP),
Sendmax(usd(1)),
Txflags(tfNoRippleDirect));
env.close();
env.require(Balance(alice, usd(0)));
env.require(Balance(carol, carolXRP + drops(1'000)));
BEAST_EXPECT(!env.le(keylet::mptoken(usd.issuanceID(), gw.id())));
BEAST_EXPECT(offersOnAccount(env, gw).empty());
}
}
void
testSelfPayment1(FeatureBitset features)
{
@@ -2122,6 +2277,7 @@ struct FlowMPT_test : public beast::unit_test::Suite
testFalseDry(features);
testDirectStep(features);
testBookStep(features);
testOfferOwnerMPTCreation(features);
testTransferRate(features);
testSelfPayment1(features);
testSelfPayment2(features);