From 7686d16cb74bba21acf451ced447ad4a65aff8ae Mon Sep 17 00:00:00 2001 From: Gregory Tsipenyuk Date: Thu, 25 Jun 2026 11:12:24 -0400 Subject: [PATCH] Fix MPT transfer fee on small escrow --- include/xrpl/ledger/helpers/EscrowHelpers.h | 24 ++++++-- src/test/app/EscrowToken_test.cpp | 68 +++++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/include/xrpl/ledger/helpers/EscrowHelpers.h b/include/xrpl/ledger/helpers/EscrowHelpers.h index bdb83230eb..31e55b92b9 100644 --- a/include/xrpl/ledger/helpers/EscrowHelpers.h +++ b/include/xrpl/ledger/helpers/EscrowHelpers.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -213,10 +214,25 @@ escrowUnlockApplyHelper( auto finalAmt = amount; if ((!senderIssuer && !receiverIssuer) && lockedRate != kParityRate) { - // compute transfer fee, if any - auto const xferFee = amount.value() - divideRound(amount, lockedRate, amount.asset(), true); - // compute balance to transfer - finalAmt = amount.value() - xferFee; + if (view.rules().enabled(featureMPTokensV2)) + { + XRPL_ASSERT( + lockedRate >= kParityRate, + "xrpl::escrowUnlockApplyHelper : lockedRate is at least parity"); + // MPTs are integral, so round the delivered amount down and + // charge any fractional transfer fee to the escrowed amount. + auto const delivered = + mulRatio(amount.mpt(), kParityRate.value, lockedRate.value, false); + finalAmt = STAmount(amount.asset(), delivered.value()); + } + else + { + // compute transfer fee, if any + auto const xferFee = + amount.value() - divideRound(amount, lockedRate, amount.asset(), true); + // compute balance to transfer + finalAmt = amount.value() - xferFee; + } } return unlockEscrowMPT( view, diff --git a/src/test/app/EscrowToken_test.cpp b/src/test/app/EscrowToken_test.cpp index 5bb1303dba..9384fdc6b7 100644 --- a/src/test/app/EscrowToken_test.cpp +++ b/src/test/app/EscrowToken_test.cpp @@ -3679,6 +3679,72 @@ struct EscrowToken_test : public beast::unit_test::Suite } } + void + testMPTSplitEscrowTransferFee(FeatureBitset features) + { + using namespace test::jtx; + using namespace std::literals; + + bool const withMPTokensV2 = features[featureMPTokensV2]; + testcase( + std::string("MPT Split Escrow Transfer Fee ") + + (withMPTokensV2 ? "with MPTokensV2" : "without MPTokensV2")); + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + auto const alice = Account("alice"); + auto const bob = Account("bob"); + auto const gw = Account("gw"); + env.fund(XRP(1'000), alice, bob, gw); + env.close(); + + MPTTester const mpt({ + .env = env, + .issuer = gw, + .holders = {alice, bob}, + .transferFee = 1'000, + .flags = tfMPTCanEscrow | tfMPTCanTransfer, + }); + env(pay(gw, alice, mpt(10'000))); + env.close(); + + static constexpr int escrowCount = 10; + static constexpr int splitAmount = 10; + static constexpr int totalLocked = escrowCount * splitAmount; + std::array seqs{}; + for (auto& seq : seqs) + { + seq = env.seq(alice); + env(escrow::create(alice, bob, mpt(splitAmount)), + escrow::kCondition(escrow::kCb1), + escrow::kFinishTime(env.now() + 1s), + Fee(baseFee * 150)); + env.close(); + } + + BEAST_EXPECT(env.balance(alice, mpt) == mpt(10'000 - totalLocked)); + BEAST_EXPECT(env.balance(bob, mpt) == mpt(0)); + BEAST_EXPECT(env.balance(gw, mpt) == mpt(-10'000)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == totalLocked); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == totalLocked); + + for (auto const seq : seqs) + { + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150)); + env.close(); + } + + auto const feeBurned = withMPTokensV2 ? escrowCount : 0; + BEAST_EXPECT(env.balance(alice, mpt) == mpt(10'000 - totalLocked)); + BEAST_EXPECT(env.balance(bob, mpt) == mpt(totalLocked - feeBurned)); + BEAST_EXPECT(env.balance(gw, mpt) == mpt(-10'000 + feeBurned)); + BEAST_EXPECT(mptEscrowed(env, alice, mpt) == 0); + BEAST_EXPECT(issuerMPTEscrowed(env, mpt) == 0); + } + void testMPTRequireAuth(FeatureBitset features) { @@ -3997,6 +4063,8 @@ public: testMPTWithFeats(feats); testMPTWithFeats(feats - fixTokenEscrowV1); } + testMPTSplitEscrowTransferFee(all - featureMPTokensV2); + testMPTSplitEscrowTransferFee(all); } };