diff --git a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp index 3483384869..5d73427236 100644 --- a/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp +++ b/src/libxrpl/tx/transactors/escrow/EscrowFinish.cpp @@ -346,6 +346,16 @@ EscrowFinish::doApply() } } + // With the Sponsor amendment, release the escrow reserve before delivery. + // Token delivery can auto-create a destination holding, and the same + // sponsor (or the same account, for a self-escrow) may cover both the + // escrow being removed and the holding being created. Without the + // amendment, keep the legacy order: releasing early changes the reserve + // arithmetic for self-escrows and would break consensus if not gated. + bool const sponsorEnabled = ctx_.view().rules().enabled(featureSponsor); + if (sponsorEnabled) + decreaseOwnerCountForObject(ctx_.view(), account, slep, 1, ctx_.journal); + STAmount const amount = slep->getFieldAmount(sfAmount); // Transfer amount to destination if (isXRP(amount)) @@ -395,8 +405,9 @@ EscrowFinish::doApply() ctx_.view().update(sled); - // Adjust source owner count - decreaseOwnerCountForObject(ctx_.view(), account, slep, 1, ctx_.journal); + // Adjust source owner count (legacy position, pre-Sponsor) + if (!sponsorEnabled) + decreaseOwnerCountForObject(ctx_.view(), account, slep, 1, ctx_.journal); // Remove escrow from ledger ctx_.view().erase(slep); diff --git a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp index 9ea159e8a8..7abce5b774 100644 --- a/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp +++ b/src/libxrpl/tx/transactors/sponsor/SponsorshipTransfer.cpp @@ -8,7 +8,6 @@ #include #include #include -#include #include #include #include @@ -20,17 +19,74 @@ #include #include #include -#include namespace xrpl { -static std::optional -applyCountDelta(std::uint32_t current, std::int64_t delta) +// Increment an uint32 sponsor count field and update the SLE. +static TER +incrementSponsorCount( + ApplyView& view, + SLE::ref sle, + SF_UINT32 const& field, + std::uint32_t const delta) { - std::int64_t const next = static_cast(current) + delta; - if (next < 0 || next > std::numeric_limits::max()) - return std::nullopt; - return static_cast(next); + auto const currentValue = sle->getFieldU32(field); + if (std::numeric_limits::max() - currentValue < delta) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::incrementSponsorCount : sponsor field overflow"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + sle->at(field) = currentValue + delta; + view.update(sle); + return tesSUCCESS; +} + +// Decrement an uint32 sponsor count field and update the SLE. +static TER +decrementSponsorCount( + ApplyView& view, + SLE::ref sle, + SF_UINT32 const& field, + std::uint32_t const delta) +{ + auto const currentValue = sle->getFieldU32(field); + if (currentValue < delta) + { + // LCOV_EXCL_START + UNREACHABLE("xrpl::decrementSponsorCount : sponsor field underflow"); + return tecINTERNAL; + // LCOV_EXCL_STOP + } + + sle->at(field) = currentValue - delta; + view.update(sle); + return tesSUCCESS; +} + +// Consume the sponsor's pre-funded reserve budget and lowers the Sponsorship +// object's RemainingOwnerCount. +static TER +decrementPrefundedReserveCount(ApplyView& view, SLE::ref sponsorshipSle, std::uint32_t const delta) +{ + if (delta == 0) + return tesSUCCESS; // LCOV_EXCL_LINE + + auto const currentReserveCount = sponsorshipSle->getFieldU32(sfRemainingOwnerCount); + if (currentReserveCount < delta) + { + // LCOV_EXCL_START + // Already verified by checkReserve (sufficient RemainingOwnerCount) + UNREACHABLE("xrpl::decrementPrefundedReserveCount : invalid reserve count"); + return tefINTERNAL; + // LCOV_EXCL_STOP + } + + sponsorshipSle->at(sfRemainingOwnerCount) = currentReserveCount - delta; + view.update(sponsorshipSle); + return tesSUCCESS; } std::uint32_t @@ -233,79 +289,30 @@ SponsorshipTransfer::preclaim(PreclaimContext const& ctx) return tesSUCCESS; } -static TER -reduceReserveCount( - ApplyView& view, - AccountID const& account, - AccountID const& sponsor, - int64_t delta) -{ - if (delta == 0) - return tesSUCCESS; - if (delta > 0) - return tefINTERNAL; // LCOV_EXCL_LINE - - auto const sponsorKeylet = keylet::sponsorship(sponsor, account); - auto const sponsorSle = view.peek(sponsorKeylet); - if (!sponsorSle) - return tefINTERNAL; // LCOV_EXCL_LINE - - auto const afterReserveCount = - applyCountDelta(sponsorSle->getFieldU32(sfRemainingOwnerCount), delta); - if (!afterReserveCount) - { - // already checked in preclaim() - UNREACHABLE("xrpl::reduceReserveCount : invalid reserve count"); - return tefINTERNAL; // LCOV_EXCL_LINE - } - - sponsorSle->at(sfRemainingOwnerCount) = *afterReserveCount; - view.update(sponsorSle); - return tesSUCCESS; -} - TER SponsorshipTransfer::doApply() { - auto const& tx = ctx_.tx; + auto const objectID = ctx_.tx[~sfObjectID]; - auto const index = tx[~sfObjectID]; - bool const isObjectSponsor = index != std::nullopt; - - auto const sponseeID = tx[~sfSponsee].value_or(accountID_); + auto const sponseeID = ctx_.tx[~sfSponsee].value_or(accountID_); auto const sponseeSle = view().peek(keylet::account(sponseeID)); if (!sponseeSle) return tefINTERNAL; // LCOV_EXCL_LINE - auto const setSponsorFieldU32 = - [] [[nodiscard]] (auto const& sle, auto const& field, auto const& delta) -> TER { - auto const newValue = applyCountDelta(sle->getFieldU32(field), delta); - if (!newValue) - { - UNREACHABLE("xrpl::SponsorshipTransfer::doApply : Invalid sponsor field value"); - return tecINTERNAL; // LCOV_EXCL_LINE - } - - sle->at(field) = *newValue; - return tesSUCCESS; - }; - auto const balanceBeforeFee = [&](SLE::const_ref sle) -> STAmount { if (sle->getAccountID(sfAccount) == accountID_) return STAmount{preFeeBalance_}; return sle->getFieldAmount(sfBalance); }; - if (isObjectSponsor) + if (objectID.has_value()) { - auto const hasSignature = tx.isFieldPresent(sfSponsorSignature); - - // transfer object sponsor - auto const objSle = view().peek(keylet::unchecked(*index)); - if (!objSle) + // Transfer object sponsor + auto const objectSle = view().peek(keylet::unchecked(*objectID)); + if (!objectSle) return tefINTERNAL; // LCOV_EXCL_LINE - auto const ownerID = getLedgerEntryOwner(view(), objSle, sponseeID); + auto const ownerID = getLedgerEntryOwner(view(), objectSle, sponseeID); if (!ownerID) return tefINTERNAL; // LCOV_EXCL_LINE @@ -313,19 +320,24 @@ SponsorshipTransfer::doApply() if (!ownerSle) return tefINTERNAL; // LCOV_EXCL_LINE - auto const ownerCountDelta = static_cast(getLedgerEntryOwnerCount(objSle)); - - auto const& sponsorField = getLedgerEntrySponsorField(objSle, *ownerID); + auto const ownerCountDelta = static_cast(getLedgerEntryOwnerCount(objectSle)); + auto const& sponsorField = getLedgerEntrySponsorField(objectSle, *ownerID); if (ctx_.tx.isFlag(tfSponsorshipCreate)) { - auto const newSponsorID = tx.getAccountID(sfSponsor); - XRPL_ASSERT(!!newSponsorID, "New sponsor is required when creating sponsorship"); + // Create object sponsor + auto const newSponsor = ctx_.tx[~sfSponsor]; + XRPL_ASSERT( + newSponsor.has_value(), + "xrpl::SponsorshipTransfer::doApply : sfSponsor present for object sponsor create"); + if (!newSponsor) + return tefINTERNAL; // LCOV_EXCL_LINE + auto const newSponsorID = *newSponsor; auto const newSponsorSle = view().peek(keylet::account(newSponsorID)); if (!newSponsorSle) return tefINTERNAL; // LCOV_EXCL_LINE - // check new sponsor have sufficient balance + // Check if new sponsor has sufficient balance // NOLINTNEXTLINE(readability-suspicious-call-argument) if (auto const ter = checkReserve( ctx_.getApplyViewContext(), @@ -337,48 +349,55 @@ SponsorshipTransfer::doApply() !isTesSuccess(ter)) return ter; - // update owner's sponsored count + // Update owner's sponsored count if (auto const ter = - setSponsorFieldU32(ownerSle, sfSponsoredOwnerCount, ownerCountDelta); + incrementSponsorCount(view(), ownerSle, sfSponsoredOwnerCount, ownerCountDelta); !isTesSuccess(ter)) return ter; - view().update(ownerSle); - // increment new sponsor's sponsoring count - if (auto const ter = - setSponsorFieldU32(newSponsorSle, sfSponsoringOwnerCount, ownerCountDelta); + // Increment new sponsor's sponsoring count + if (auto const ter = incrementSponsorCount( + view(), newSponsorSle, sfSponsoringOwnerCount, ownerCountDelta); !isTesSuccess(ter)) return ter; - view().update(newSponsorSle); - // set new sponsor to object - objSle->setAccountID(sponsorField, newSponsorID); - view().update(objSle); + // Object is now sponsored by new sponsor + objectSle->setAccountID(sponsorField, newSponsorID); + view().update(objectSle); - if (!hasSignature) + auto const sponsorshipSle = view().peek(keylet::sponsorship(newSponsorID, sponseeID)); + if (sponsorshipSle) { - // use ReserveCount for pre-funded sponsoring + // Update ReserveCount for sponsorship object if it exists if (auto const ter = - reduceReserveCount(view(), sponseeID, newSponsorID, -ownerCountDelta); + decrementPrefundedReserveCount(view(), sponsorshipSle, ownerCountDelta); !isTesSuccess(ter)) return ter; } } else if (ctx_.tx.isFlag(tfSponsorshipReassign)) { - auto const newSponsorID = tx.getAccountID(sfSponsor); - XRPL_ASSERT(!!newSponsorID, "New sponsor is required when reassigning sponsorship"); + // Reassign object sponsor + auto const newSponsor = ctx_.tx[~sfSponsor]; + XRPL_ASSERT( + newSponsor.has_value(), + "xrpl::SponsorshipTransfer::doApply : sfSponsor present for object sponsor " + "reassign"); + if (!newSponsor) + return tefINTERNAL; // LCOV_EXCL_LINE + auto const newSponsorID = *newSponsor; auto const newSponsorSle = view().peek(keylet::account(newSponsorID)); if (!newSponsorSle) return tefINTERNAL; // LCOV_EXCL_LINE - auto const oldSponsorID = objSle->getAccountID(sponsorField); - XRPL_ASSERT(!!oldSponsorID, "Old sponsor is required when reassigning sponsorship"); + auto const oldSponsorID = objectSle->getAccountID(sponsorField); + if (!oldSponsorID) + return tefINTERNAL; // LCOV_EXCL_LINE auto const oldSponsorSle = view().peek(keylet::account(oldSponsorID)); if (!oldSponsorSle) return tefINTERNAL; // LCOV_EXCL_LINE - // check new sponsor have sufficient balance + // Check if new sponsor has sufficient balance // NOLINTNEXTLINE(readability-suspicious-call-argument) if (auto const ter = checkReserve( ctx_.getApplyViewContext(), @@ -390,38 +409,38 @@ SponsorshipTransfer::doApply() !isTesSuccess(ter)) return ter; - // decrement old sponsor's sponsoring count - if (auto const ter = - setSponsorFieldU32(oldSponsorSle, sfSponsoringOwnerCount, -ownerCountDelta); + // Decrement old sponsor's sponsoring count + if (auto const ter = decrementSponsorCount( + view(), oldSponsorSle, sfSponsoringOwnerCount, ownerCountDelta); !isTesSuccess(ter)) return ter; - view().update(oldSponsorSle); - // increment new sponsor's sponsoring count - if (auto const ter = - setSponsorFieldU32(newSponsorSle, sfSponsoringOwnerCount, ownerCountDelta); + // Increment new sponsor's sponsoring count + if (auto const ter = incrementSponsorCount( + view(), newSponsorSle, sfSponsoringOwnerCount, ownerCountDelta); !isTesSuccess(ter)) return ter; - view().update(newSponsorSle); - // set new sponsor to object - objSle->setAccountID(sponsorField, newSponsorID); - view().update(objSle); + // Object is now sponsored by new sponsor + objectSle->setAccountID(sponsorField, newSponsorID); + view().update(objectSle); - if (!hasSignature) + auto const sponsorshipSle = view().peek(keylet::sponsorship(newSponsorID, sponseeID)); + if (sponsorshipSle) { - // use ReserveCount for pre-funded sponsoring + // Update ReserveCount for sponsorship object if it exists if (auto const ter = - reduceReserveCount(view(), sponseeID, newSponsorID, -ownerCountDelta); + decrementPrefundedReserveCount(view(), sponsorshipSle, ownerCountDelta); !isTesSuccess(ter)) return ter; } } else if (ctx_.tx.isFlag(tfSponsorshipEnd)) { - auto const oldSponsorID = objSle->getAccountID(sponsorField); - XRPL_ASSERT(!!oldSponsorID, "Old sponsor is required when ending sponsorship"); - + // End object sponsor + auto const oldSponsorID = objectSle->getAccountID(sponsorField); + if (!oldSponsorID) + return tefINTERNAL; // LCOV_EXCL_LINE auto const oldSponsorSle = view().peek(keylet::account(oldSponsorID)); if (!oldSponsorSle) return tefINTERNAL; // LCOV_EXCL_LINE @@ -438,32 +457,38 @@ SponsorshipTransfer::doApply() !isTesSuccess(ter)) return ter; - // decrement sponsored count - if (auto const ter = - setSponsorFieldU32(sponseeSle, sfSponsoredOwnerCount, -ownerCountDelta); + // Decrement sponsored count + if (auto const ter = decrementSponsorCount( + view(), sponseeSle, sfSponsoredOwnerCount, ownerCountDelta); !isTesSuccess(ter)) return ter; - view().update(sponseeSle); - // decrement old sponsoring count - if (auto const ter = - setSponsorFieldU32(oldSponsorSle, sfSponsoringOwnerCount, -ownerCountDelta); + // Decrement old sponsoring count + if (auto const ter = decrementSponsorCount( + view(), oldSponsorSle, sfSponsoringOwnerCount, ownerCountDelta); !isTesSuccess(ter)) return ter; - view().update(oldSponsorSle); - // remove sponsor from object - objSle->makeFieldAbsent(sponsorField); - view().update(objSle); + // Remove sponsor from object + objectSle->makeFieldAbsent(sponsorField); + view().update(objectSle); } } else { + // Account-level sponsorship is always co-signed (preflight requires + // sfSponsorSignature), so there is no pre-funded budget to draw down here. if (ctx_.tx.isFlag(tfSponsorshipCreate)) { - // create account sponsor - // increment new sponsoring count - auto const newSponsorID = tx.getAccountID(sfSponsor); + // Create account sponsor + auto const newSponsor = ctx_.tx[~sfSponsor]; + XRPL_ASSERT( + newSponsor.has_value(), + "xrpl::SponsorshipTransfer::doApply : sfSponsor present for account sponsor " + "create"); + if (!newSponsor) + return tefINTERNAL; // LCOV_EXCL_LINE + auto const newSponsorID = *newSponsor; auto const newSponsorSle = view().peek(keylet::account(newSponsorID)); if (!newSponsorSle) return tefINTERNAL; // LCOV_EXCL_LINE @@ -478,20 +503,27 @@ SponsorshipTransfer::doApply() !isTesSuccess(ter)) return ter; - if (auto const ter = setSponsorFieldU32(newSponsorSle, sfSponsoringAccountCount, 1); + // Increment new sponsoring count + if (auto const ter = + incrementSponsorCount(view(), newSponsorSle, sfSponsoringAccountCount, 1); !isTesSuccess(ter)) return ter; - view().update(newSponsorSle); - // set new sponsor to account + // Account is now sponsored by new sponsor sponseeSle->setAccountID(sfSponsor, newSponsorID); view().update(sponseeSle); } else if (ctx_.tx.isFlag(tfSponsorshipReassign)) { - // reassign account sponsor - // increment new sponsoring count - auto const newSponsorID = tx.getAccountID(sfSponsor); + // Reassign account sponsor + auto const newSponsor = ctx_.tx[~sfSponsor]; + XRPL_ASSERT( + newSponsor.has_value(), + "xrpl::SponsorshipTransfer::doApply : sfSponsor present for account sponsor " + "reassign"); + if (!newSponsor) + return tefINTERNAL; // LCOV_EXCL_LINE + auto const newSponsorID = *newSponsor; auto const newSponsorSle = view().peek(keylet::account(newSponsorID)); if (!newSponsorSle) return tefINTERNAL; // LCOV_EXCL_LINE @@ -506,29 +538,37 @@ SponsorshipTransfer::doApply() !isTesSuccess(ter)) return ter; - if (auto const ter = setSponsorFieldU32(newSponsorSle, sfSponsoringAccountCount, 1); + // Increment new sponsoring count + if (auto const ter = + incrementSponsorCount(view(), newSponsorSle, sfSponsoringAccountCount, 1); !isTesSuccess(ter)) return ter; - view().update(newSponsorSle); - // decrement old sponsoring count + // Decrement old sponsoring count auto const oldSponsorID = sponseeSle->getAccountID(sfSponsor); + if (!oldSponsorID) + return tefINTERNAL; // LCOV_EXCL_LINE auto const oldSponsorSle = view().peek(keylet::account(oldSponsorID)); if (!oldSponsorSle) return tefINTERNAL; // LCOV_EXCL_LINE - if (auto const ter = setSponsorFieldU32(oldSponsorSle, sfSponsoringAccountCount, -1); + if (auto const ter = + decrementSponsorCount(view(), oldSponsorSle, sfSponsoringAccountCount, 1); !isTesSuccess(ter)) return ter; - view().update(oldSponsorSle); - // set new sponsor to account + // Account is now sponsored by new sponsor sponseeSle->setAccountID(sfSponsor, newSponsorID); view().update(sponseeSle); } else if (ctx_.tx.isFlag(tfSponsorshipEnd)) { - // dissolve account sponsor + // End account sponsor auto const oldSponsorID = sponseeSle->getAccountID(sfSponsor); + if (!oldSponsorID) + return tefINTERNAL; // LCOV_EXCL_LINE + auto const oldSponsorSle = view().peek(keylet::account(oldSponsorID)); + if (!oldSponsorSle) + return tefINTERNAL; // LCOV_EXCL_LINE // The sponsee must be able to hold its own account reserve after // the sponsorship is removed. @@ -545,14 +585,11 @@ SponsorshipTransfer::doApply() sponseeSle->makeFieldAbsent(sfSponsor); view().update(sponseeSle); - // decrement account sponsoring count - auto const oldSponsorSle = view().peek(keylet::account(oldSponsorID)); - if (!oldSponsorSle) - return tefINTERNAL; // LCOV_EXCL_LINE - if (auto const ter = setSponsorFieldU32(oldSponsorSle, sfSponsoringAccountCount, -1); + // Decrement account sponsoring count + if (auto const ter = + decrementSponsorCount(view(), oldSponsorSle, sfSponsoringAccountCount, 1); !isTesSuccess(ter)) return ter; - view().update(oldSponsorSle); } } diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index f5297bb19f..01a6ca25fe 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -3241,6 +3241,84 @@ public: env.le(keylet::trustLine(bob, gw, usd.currency))->getAccountID(sfHighSponsor) == sponsor2.id()); } + { + // IOU EscrowFinish recycles reserve when the same sponsor backs + // the escrow being removed and the destination line being created. + Env env{*this, testableAmendments()}; + auto const baseFee = env.current()->fees().base; + + env.fund(XRP(1000000), alice, bob, gw, sponsor); + env.close(); + + env(fset(gw, asfAllowTrustLineLocking)); + env.close(); + + env.trust(usd(1000000), alice); + env.close(); + env(pay(gw, alice, usd(10000))); + env.close(); + + uint32_t seq = 0; + testEachSponsorship( + env, + cosigning, + sponsor, + alice, + 1, + 1, + tecINSUFFICIENT_RESERVE, + [&](Env& env, auto const& submit) { + seq = env.seq(alice); + submit( + escrow::create(alice, bob, usd(100)), + escrow::kCondition(escrow::kCb1), + escrow::kCancelTime(env.now() + 100s)); + }); + + BEAST_EXPECT( + env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id()); + + if (cosigning) + { + adjustAccountXRPBalance(env, sponsor, reserve(env, 1)); + } + else + { + env(sponsor::set_reserve(sponsor, 0, 1), sponsor::SponseeAcc(bob)); + env.close(); + } + + if (cosigning) + { + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + } + else + { + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + sponsor::As(sponsor, spfSponsorReserve), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + } + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, bob) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + + BEAST_EXPECT( + env.le(keylet::trustLine(bob, gw, usd.currency))->getAccountID(sfHighSponsor) == + sponsor.id()); + } { // IOU Escrow cancel re-creates the owner's trust line, and the // cancel transaction's sponsor can cover that new line's reserve. @@ -3368,6 +3446,83 @@ public: env.le(keylet::mptoken(mptGw.issuanceID(), bob))->getAccountID(sfSponsor) == sponsor.id()); } + { + // MPT EscrowFinish has the same reserve recycling behavior as IOU + // when it creates the destination MPToken. + Env env{*this, testableAmendments()}; + auto const baseFee = env.current()->fees().base; + env.fund(XRP(1000000), bob, sponsor); + env.close(); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create( + {.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanEscrow | tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const mpt = mptGw["MPT"]; + env(pay(gw, alice, mpt(10'000))); + env.close(); + + uint32_t seq = 0; + testEachSponsorship( + env, + cosigning, + sponsor, + alice, + 1, + 1, + tecINSUFFICIENT_RESERVE, + [&](Env& env, auto const& submit) { + seq = env.seq(alice); + submit( + escrow::create(alice, bob, mpt(100)), + escrow::kCondition(escrow::kCb1), + escrow::kCancelTime(env.now() + 100s)); + }); + + BEAST_EXPECT( + env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id()); + + if (cosigning) + { + adjustAccountXRPBalance(env, sponsor, reserve(env, 1)); + } + else + { + env(sponsor::set_reserve(sponsor, 0, 1), sponsor::SponseeAcc(bob)); + env.close(); + } + + if (cosigning) + { + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + } + else + { + env(escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + sponsor::As(sponsor, spfSponsorReserve), + Fee(baseFee * 150), + Ter(tesSUCCESS)); + } + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, bob) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + + BEAST_EXPECT( + env.le(keylet::mptoken(mptGw.issuanceID(), bob))->getAccountID(sfSponsor) == + sponsor.id()); + } // A sponsored EscrowCreate must still verify that the source // can fund the escrow amount and stay above its own base @@ -4932,6 +5087,92 @@ public: BEAST_EXPECT(issuerSponsoredAfter == issuerSponsoredBefore); } + void + testSelfEscrowFinishReserveGate() + { + testcase("Self-escrow finish reserve order gated by amendment"); + using namespace test::jtx; + using namespace std::chrono_literals; + + // Finishing a self-escrow (source == destination) whose trust line + // was deleted while the escrow was outstanding auto-creates the line, + // and the outcome of that reserve check depends on whether the escrow + // reserve is released before delivery (Sponsor) or after (legacy). + // With the source's balance in the one-increment window + // [reserve(1), reserve(2)), the legacy order requires reserve(2) and + // fails, while the Sponsor order requires reserve(1) and succeeds. + auto runTest = [&](FeatureBitset features, TER expected) { + Account const alice("alice"); + Account const gw("gw"); + auto const usd = gw["usd"]; + + Env env{*this, features}; + auto const baseFee = env.current()->fees().base; + + env.fund(XRP(10000), alice, gw); + env.close(); + + env(fset(gw, asfAllowTrustLineLocking)); + env.close(); + + env.trust(usd(1000), alice); + env.close(); + env(pay(gw, alice, usd(100))); + env.close(); + + // Escrow alice's entire USD balance to herself. The escrowed + // IOUs return to the issuer, zeroing the line balance. + auto const seq = env.seq(alice); + env(escrow::create(alice, alice, usd(100)), + escrow::kCondition(escrow::kCb1), + escrow::kCancelTime(env.now() + 100s)); + env.close(); + + // Delete the now-empty trust line. Both accounts have + // DefaultRipple set (jtx fund does that), so a plain limit-0 + // TrustSet returns the line to its default state. + env(trust(alice, usd(0))); + env.close(); + BEAST_EXPECT(!env.le(keylet::trustLine(alice, gw, usd.currency))); + BEAST_EXPECT(ownerCount(env, alice) == 1); // just the escrow + + // Put alice's balance in the window. Pay the excess away + // directly: adjustAccountXRPBalance needs the Sponsor amendment. + STAmount const target = reserve(env, 1) + XRP(1); + env(pay(alice, env.master, env.balance(alice) - target - baseFee), Fee(baseFee)); + env.close(); + BEAST_EXPECT(env.balance(alice) == target); + + env(escrow::finish(alice, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(baseFee * 150), + Ter(expected)); + env.close(); + + if (expected == tesSUCCESS) + { + BEAST_EXPECT(!env.le(keylet::escrow(alice, seq))); + BEAST_EXPECT(env.le(keylet::trustLine(alice, gw, usd.currency))); + BEAST_EXPECT(env.balance(alice, usd) == usd(100)); + BEAST_EXPECT(ownerCount(env, alice) == 1); // the new line + } + else + { + BEAST_EXPECT(env.le(keylet::escrow(alice, seq))); + BEAST_EXPECT(!env.le(keylet::trustLine(alice, gw, usd.currency))); + BEAST_EXPECT(ownerCount(env, alice) == 1); // still the escrow + } + }; + + // Pre-amendment: legacy order — the escrow still counts against the + // reserve while the auto-created line is checked. + runTest(testableAmendments() - featureSponsor, tecNO_LINE_INSUF_RESERVE); + + // Post-amendment: the escrow reserve is recycled into the new line. + runTest(testableAmendments(), tesSUCCESS); + } + void testFeeSponsoredVaultInvariant() { @@ -5092,6 +5333,7 @@ protected: testZeroBalanceSponsoredPaymentFeePayerCheck(); testTrustSetCounterpartySponsorMisroute(); + testSelfEscrowFinishReserveGate(); testFeeSponsoredVaultInvariant(); testSponsoredObjectDeletionRefund();