From 01164a3ec02acfe17f3e798737b109b349653c3b Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Sat, 16 May 2026 06:38:55 -0400 Subject: [PATCH] Defer MPT creation until offer consume --- src/libxrpl/tx/paths/BookStep.cpp | 30 +++--- src/test/app/FlowMPT_test.cpp | 156 ++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 13 deletions(-) diff --git a/src/libxrpl/tx/paths/BookStep.cpp b/src/libxrpl/tx/paths/BookStep.cpp index e3f1b39f5b..19235d9a75 100644 --- a/src/libxrpl/tx/paths/BookStep.cpp +++ b/src/libxrpl/tx/paths/BookStep.cpp @@ -740,18 +740,6 @@ BookStep::forEachOffer( bool const isAssetInMPT = assetIn.holds(); 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(), 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::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::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) + { + // 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(), offer.owner(), j_); + !isTesSuccess(err)) + Throw(err); + } + auto const dr = offer.send( sb, book_.in.getIssuer(), offer.owner(), toSTAmount(ofrAmt.in, book_.in), j_); if (!isTesSuccess(dr)) diff --git a/src/test/app/FlowMPT_test.cpp b/src/test/app/FlowMPT_test.cpp index 9bb91755c8..c90cbc774c 100644 --- a/src/test/app/FlowMPT_test.cpp +++ b/src/test/app/FlowMPT_test.cpp @@ -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);