Compare commits

...

1 Commits

Author SHA1 Message Date
Denis Angell
9d74e0af3b feat: Add sponsor reap for abandoned under-reserved accounts 2026-08-05 17:05:46 -04:00
11 changed files with 665 additions and 231 deletions

View File

@@ -1,5 +1,6 @@
#pragma once
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
@@ -201,4 +202,16 @@ getLedgerEntryOwnerCount(SLE const& sle);
SF_ACCOUNT const&
getLedgerEntrySponsorField(SLE const& sle, AccountID const& owner);
/**
* Delete a Sponsorship (ltSPONSORSHIP) ledger object cleanly.
*
* Unlinks the object from both the sponsor's and sponsee's owner directories,
* releases the sponsor's owner reserve, refunds any prefunded sfFeeAmount to
* the sponsor, and erases the object. Shared by SponsorshipSet (tfDeleteObject)
* and AccountDelete's non-obligation deleter so a sponsored or sponsoring
* account can be deleted without its Sponsorship object blocking it.
*/
[[nodiscard]] TER
deleteSponsorshipObject(ApplyView& view, SLE::ref sle, beast::Journal j);
} // namespace xrpl

View File

@@ -230,7 +230,8 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal;
TRANSACTION(SponsorshipTransfer, \
TF_FLAG(tfSponsorshipEnd, 0x00010000) \
TF_FLAG(tfSponsorshipCreate, 0x00020000) \
TF_FLAG(tfSponsorshipReassign, 0x00040000), \
TF_FLAG(tfSponsorshipReassign, 0x00040000) \
TF_FLAG(tfSponsorshipReap, 0x00080000), \
MASK_ADJ(0))
// clang-format on

View File

@@ -1172,7 +1172,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback,
TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer,
Delegation::NotDelegable,
featureSponsor,
NoPriv,
MayDeleteAcct,
({
{sfObjectID, SoeOptional},
{sfSponsee, SoeOptional},

View File

@@ -21,7 +21,7 @@ class SponsorshipTransferBuilder;
* Type: ttSPONSORSHIP_TRANSFER (90)
* Delegable: Delegation::NotDelegable
* Amendment: featureSponsor
* Privileges: NoPriv
* Privileges: MayDeleteAcct
*
* Immutable wrapper around STTx providing type-safe field access.
* Use SponsorshipTransferBuilder to construct new transactions.

View File

@@ -3,6 +3,8 @@
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/STLedgerEntry.h>
#include <xrpl/protocol/STTx.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/XRPAmount.h>
@@ -11,6 +13,37 @@
namespace xrpl {
/**
* Check that an account satisfies the preconditions for deletion.
*
* Verifies there are no NFT obligations, the account is not sponsoring others,
* the account's sequence is outside the 256-ledger replay window, and every
* object it owns is a non-obligation deletable type. When the account carries a
* reserve sponsor (sfSponsor), @p dst must be that sponsor.
*
* Shared by AccountDelete::preclaim and the SponsorshipTransfer reap path so
* both enforce identical deletion safety.
*/
[[nodiscard]] TER
checkAccountDeletable(
ReadView const& view,
SLE::const_ref sleAccount,
AccountID const& dst,
beast::Journal j);
/**
* Delete @p src, sweeping its residual XRP to @p dst.
*
* Tears down every non-obligation object @p src owns, transfers its remaining
* balance to @p dst, unwinds any reserve sponsorship (decrementing the
* sponsor's sfSponsoringAccountCount and clearing sfSponsor), removes the owner
* directory, and erases the account root. Callers must have already confirmed
* the account is deletable via checkAccountDeletable. Shared by
* AccountDelete::doApply and the SponsorshipTransfer reap path.
*/
[[nodiscard]] TER
applyAccountDelete(ApplyContext& ctx, SLE::pointer src, SLE::pointer dst, beast::Journal j);
class AccountDelete : public Transactor
{
public:

View File

@@ -25,6 +25,9 @@ public:
static std::uint32_t
getFlagsMask(PreflightContext const& ctx);
static XRPAmount
calculateBaseFee(ReadView const& view, STTx const& tx);
static NotTEC
preflight(PreflightContext const& ctx);

View File

@@ -1,5 +1,6 @@
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/basics/Log.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
@@ -342,4 +343,48 @@ getLedgerEntrySponsorField(SLE const& sle, AccountID const& owner)
}
}
TER
deleteSponsorshipObject(ApplyView& view, SLE::ref sle, beast::Journal j)
{
if (!sle)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const sponsorID = (*sle)[sfOwner];
auto const sponseeID = (*sle)[sfSponsee];
// The sponsor owns the Sponsorship object, so deletion releases the
// sponsor's owner reserve.
auto sponsorAccSle = view.peek(keylet::account(sponsorID));
if (!sponsorAccSle)
return tecINTERNAL; // LCOV_EXCL_LINE
if (!view.dirRemove(keylet::ownerDir(sponsorID), (*sle)[sfOwnerNode], sle->key(), false))
{
// LCOV_EXCL_START
JLOG(j.fatal()) << "Unable to delete Sponsorship from sponsor.";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
if (!view.dirRemove(keylet::ownerDir(sponseeID), (*sle)[sfSponseeNode], sle->key(), false))
{
// LCOV_EXCL_START
JLOG(j.fatal()) << "Unable to delete Sponsorship from sponsee.";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
decreaseOwnerCountForObject(view, sponsorAccSle, sle, 1, j);
// Return any prefunded fee amount to the sponsor before erasing the object.
if (sle->isFieldPresent(sfFeeAmount))
{
(*sponsorAccSle)[sfBalance] += sle->getFieldAmount(sfFeeAmount);
view.update(sponsorAccSle);
}
view.erase(sle);
return tesSUCCESS;
}
} // namespace xrpl

View File

@@ -13,6 +13,7 @@
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/ledger/helpers/NFTokenHelpers.h>
#include <xrpl/ledger/helpers/OfferHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -185,6 +186,18 @@ removeDelegateFromLedger(
return DelegateSet::deleteDelegate(view, sleDel, j);
}
TER
removeSponsorshipFromLedger(
ServiceRegistry&,
ApplyView& view,
AccountID const&,
uint256 const&,
SLE::ref sleDel,
beast::Journal j)
{
return deleteSponsorshipObject(view, sleDel, j);
}
// Return nullptr if the LedgerEntryType represents an obligation that can't
// be deleted. Otherwise return the pointer to the function that can delete
// the non-obligation
@@ -211,6 +224,8 @@ nonObligationDeleter(LedgerEntryType t)
return removeCredentialFromLedger;
case ltDELEGATE:
return removeDelegateFromLedger;
case ltSPONSORSHIP:
return removeSponsorshipFromLedger;
default:
return nullptr;
}
@@ -218,6 +233,198 @@ nonObligationDeleter(LedgerEntryType t)
} // namespace
TER
checkAccountDeletable(
ReadView const& view,
SLE::const_ref sleAccount,
AccountID const& dst,
beast::Journal j)
{
AccountID const account = sleAccount->getAccountID(sfAccount);
// If an issuer has any issued NFTs resident in the ledger then it
// cannot be deleted.
if ((*sleAccount)[~sfMintedNFTokens] != (*sleAccount)[~sfBurnedNFTokens])
return tecHAS_OBLIGATIONS;
// If the account owns any NFTs it cannot be deleted.
Keylet const first = keylet::nftokenPageMin(account);
Keylet const last = keylet::nftokenPageMax(account);
auto const cp =
view.read(Keylet(ltNFTOKEN_PAGE, view.succ(first.key, last.key.next()).value_or(last.key)));
if (cp)
return tecHAS_OBLIGATIONS;
// A sponsored account may only pay its residual XRP to its own sponsor.
if (sleAccount->isFieldPresent(sfSponsor))
{
if (dst != sleAccount->getAccountID(sfSponsor))
return tecNO_SPONSOR_PERMISSION;
}
// An account that is sponsoring others cannot be deleted.
if (sleAccount->isFieldPresent(sfSponsoringOwnerCount) ||
sleAccount->isFieldPresent(sfSponsoringAccountCount))
return tecHAS_OBLIGATIONS;
// We don't allow an account to be deleted if its sequence number
// is within 256 of the current ledger. This prevents replay of old
// transactions if this account is resurrected after it is deleted.
//
// We look at the account's Sequence rather than the transaction's
// Sequence in preparation for Tickets.
static constexpr std::uint32_t kSeqDelta{255};
if ((*sleAccount)[sfSequence] + kSeqDelta > view.seq())
return tecTOO_SOON;
// We don't allow an account to be deleted if
// <FirstNFTokenSequence + MintedNFTokens> is within 256 of the
// current ledger. This is to prevent having duplicate NFTokenIDs after
// account re-creation.
//
// Without this restriction, duplicate NFTokenIDs can be reproduced when
// authorized minting is involved. Because when the minter mints a NFToken,
// the issuer's sequence does not change. So when the issuer re-creates
// their account and mints a NFToken, it is possible that the
// NFTokenSequence of this NFToken is the same as the one that the
// authorized minter minted in a previous ledger.
if ((*sleAccount)[~sfFirstNFTokenSequence].value_or(0) +
(*sleAccount)[~sfMintedNFTokens].value_or(0) + kSeqDelta >
view.seq())
return tecTOO_SOON;
// Verify that the account does not own any objects that would prevent
// the account from being deleted.
Keylet const ownerDirKeylet{keylet::ownerDir(account)};
if (dirIsEmpty(view, ownerDirKeylet))
return tesSUCCESS;
SLE::const_pointer sleDirNode{};
unsigned int uDirEntry{0};
uint256 dirEntry{beast::kZero};
// Account has no directory at all. This _should_ have been caught
// by the dirIsEmpty() check earlier, but it's okay to catch it here.
if (!cdirFirst(view, ownerDirKeylet.key, sleDirNode, uDirEntry, dirEntry))
return tesSUCCESS;
std::uint32_t deletableDirEntryCount{0};
do
{
// Make sure any directory node types that we find are the kind
// we can delete.
auto sleItem = view.read(keylet::child(dirEntry));
if (!sleItem)
{
// Directory node has an invalid index. Bail out.
// LCOV_EXCL_START
JLOG(j.fatal()) << "AccountDelete: directory node in ledger " << view.seq()
<< " has index to object that is missing: " << to_string(dirEntry);
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
LedgerEntryType const nodeType{safeCast<LedgerEntryType>((*sleItem)[sfLedgerEntryType])};
if (nonObligationDeleter(nodeType) == nullptr)
return tecHAS_OBLIGATIONS;
// We found a deletable directory entry. Count it. If we find too
// many deletable directory entries then bail out.
if (++deletableDirEntryCount > kMaxDeletableDirEntries)
return tefTOO_BIG;
} while (cdirNext(view, ownerDirKeylet.key, sleDirNode, uDirEntry, dirEntry));
return tesSUCCESS;
}
TER
applyAccountDelete(ApplyContext& ctx, SLE::pointer src, SLE::pointer dst, beast::Journal j)
{
auto& view = ctx.view();
AccountID const accountID = src->getAccountID(sfAccount);
Keylet const ownerDirKeylet{keylet::ownerDir(accountID)};
auto const ter = cleanupOnAccountDelete(
view,
ownerDirKeylet,
[&](LedgerEntryType nodeType,
uint256 const& dirEntry,
SLE::pointer& sleItem) -> std::pair<TER, SkipEntry> {
if (auto deleter = nonObligationDeleter(nodeType))
{
TER const result{deleter(ctx.registry, view, accountID, dirEntry, sleItem, j)};
return {result, SkipEntry::No};
}
// LCOV_EXCL_START
UNREACHABLE("xrpl::applyAccountDelete : undeletable item not found in preclaim");
JLOG(j.error()) << "AccountDelete undeletable item not found in preclaim.";
return {tecHAS_OBLIGATIONS, SkipEntry::No};
// LCOV_EXCL_STOP
},
j);
if (!isTesSuccess(ter))
return ter;
// Transfer any XRP remaining after the fee is paid to the destination:
auto const remainingBalance = src->getFieldAmount(sfBalance).xrp();
(*dst)[sfBalance] = (*dst)[sfBalance] + remainingBalance;
(*src)[sfBalance] = (*src)[sfBalance] - remainingBalance;
ctx.deliver(remainingBalance);
if (src->isFieldPresent(sfSponsor))
{
auto const sponsorID = src->getAccountID(sfSponsor);
auto sponsorSle = view.peek(keylet::account(sponsorID));
if (!sponsorSle)
return tefINTERNAL; // LCOV_EXCL_LINE
auto const sponsoringAccountCount = sponsorSle->getFieldU32(sfSponsoringAccountCount);
XRPL_ASSERT(
sponsoringAccountCount != 0,
"xrpl::applyAccountDelete : sponsoring account count is present");
if (sponsoringAccountCount == 0)
{
// sanity check
// Since sfSponsoringAccountCount is set to soeDEFAULT, the field will not be
// present with a value of 0.
return tefINTERNAL; // LCOV_EXCL_LINE
}
sponsorSle->at(sfSponsoringAccountCount) = sponsoringAccountCount - 1;
view.update(sponsorSle);
// Following line might look redundant, but without it, sfSponsor
// would end up remaining in after-ltAccountRoot during the
// InvariantCheck.
src->makeFieldAbsent(sfSponsor);
}
XRPL_ASSERT(
(*src)[sfBalance] == XRPAmount(0), "xrpl::applyAccountDelete : source balance is zero");
// If there's still an owner directory associated with the source account
// delete it.
if (view.exists(ownerDirKeylet) && !view.emptyDirDelete(ownerDirKeylet))
{
JLOG(j.error()) << "AccountDelete cannot delete root dir node of " << toBase58(accountID);
return tecHAS_OBLIGATIONS;
}
// Re-arm the password change fee if we can and need to.
if (remainingBalance > XRPAmount(0) && dst->isFlag(lsfPasswordSpent))
dst->clearFlag(lsfPasswordSpent);
view.update(dst);
view.erase(src);
return tesSUCCESS;
}
TER
AccountDelete::preclaim(PreclaimContext const& ctx)
{
@@ -253,99 +460,7 @@ AccountDelete::preclaim(PreclaimContext const& ctx)
if (!sleAccount)
return terNO_ACCOUNT;
// If an issuer has any issued NFTs resident in the ledger then it
// cannot be deleted.
if ((*sleAccount)[~sfMintedNFTokens] != (*sleAccount)[~sfBurnedNFTokens])
return tecHAS_OBLIGATIONS;
// If the account owns any NFTs it cannot be deleted.
Keylet const first = keylet::nftokenPageMin(account);
Keylet const last = keylet::nftokenPageMax(account);
auto const cp = ctx.view.read(
Keylet(ltNFTOKEN_PAGE, ctx.view.succ(first.key, last.key.next()).value_or(last.key)));
if (cp)
return tecHAS_OBLIGATIONS;
if (sleAccount->isFieldPresent(sfSponsor))
{
if (dst != sleAccount->getAccountID(sfSponsor))
return tecNO_SPONSOR_PERMISSION;
}
if (sleAccount->isFieldPresent(sfSponsoringOwnerCount) ||
sleAccount->isFieldPresent(sfSponsoringAccountCount))
return tecHAS_OBLIGATIONS;
// We don't allow an account to be deleted if its sequence number
// is within 256 of the current ledger. This prevents replay of old
// transactions if this account is resurrected after it is deleted.
//
// We look at the account's Sequence rather than the transaction's
// Sequence in preparation for Tickets.
static constexpr std::uint32_t kSeqDelta{255};
if ((*sleAccount)[sfSequence] + kSeqDelta > ctx.view.seq())
return tecTOO_SOON;
// We don't allow an account to be deleted if
// <FirstNFTokenSequence + MintedNFTokens> is within 256 of the
// current ledger. This is to prevent having duplicate NFTokenIDs after
// account re-creation.
//
// Without this restriction, duplicate NFTokenIDs can be reproduced when
// authorized minting is involved. Because when the minter mints a NFToken,
// the issuer's sequence does not change. So when the issuer re-creates
// their account and mints a NFToken, it is possible that the
// NFTokenSequence of this NFToken is the same as the one that the
// authorized minter minted in a previous ledger.
if ((*sleAccount)[~sfFirstNFTokenSequence].value_or(0) +
(*sleAccount)[~sfMintedNFTokens].value_or(0) + kSeqDelta >
ctx.view.seq())
return tecTOO_SOON;
// Verify that the account does not own any objects that would prevent
// the account from being deleted.
Keylet const ownerDirKeylet{keylet::ownerDir(account)};
if (dirIsEmpty(ctx.view, ownerDirKeylet))
return tesSUCCESS;
SLE::const_pointer sleDirNode{};
unsigned int uDirEntry{0};
uint256 dirEntry{beast::kZero};
// Account has no directory at all. This _should_ have been caught
// by the dirIsEmpty() check earlier, but it's okay to catch it here.
if (!cdirFirst(ctx.view, ownerDirKeylet.key, sleDirNode, uDirEntry, dirEntry))
return tesSUCCESS;
std::uint32_t deletableDirEntryCount{0};
do
{
// Make sure any directory node types that we find are the kind
// we can delete.
auto sleItem = ctx.view.read(keylet::child(dirEntry));
if (!sleItem)
{
// Directory node has an invalid index. Bail out.
// LCOV_EXCL_START
JLOG(ctx.j.fatal()) << "AccountDelete: directory node in ledger " << ctx.view.seq()
<< " has index to object that is missing: " << to_string(dirEntry);
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
LedgerEntryType const nodeType{safeCast<LedgerEntryType>((*sleItem)[sfLedgerEntryType])};
if (nonObligationDeleter(nodeType) == nullptr)
return tecHAS_OBLIGATIONS;
// We found a deletable directory entry. Count it. If we find too
// many deletable directory entries then bail out.
if (++deletableDirEntryCount > kMaxDeletableDirEntries)
return tefTOO_BIG;
} while (cdirNext(ctx.view, ownerDirKeylet.key, sleDirNode, uDirEntry, dirEntry));
return tesSUCCESS;
return checkAccountDeletable(ctx.view, sleAccount, dst, ctx.j);
}
TER
@@ -369,87 +484,7 @@ AccountDelete::doApply()
return err;
}
Keylet const ownerDirKeylet{keylet::ownerDir(accountID_)};
auto const ter = cleanupOnAccountDelete(
view(),
ownerDirKeylet,
[&](LedgerEntryType nodeType,
uint256 const& dirEntry,
SLE::pointer& sleItem) -> std::pair<TER, SkipEntry> {
if (auto deleter = nonObligationDeleter(nodeType))
{
TER const result{deleter(ctx_.registry, view(), accountID_, dirEntry, sleItem, j_)};
return {result, SkipEntry::No};
}
// LCOV_EXCL_START
UNREACHABLE(
"xrpl::AccountDelete::doApply : undeletable item not found "
"in preclaim");
JLOG(j_.error()) << "AccountDelete undeletable item not "
"found in preclaim.";
return {tecHAS_OBLIGATIONS, SkipEntry::No};
// LCOV_EXCL_STOP
},
ctx_.journal);
if (!isTesSuccess(ter))
return ter;
// Transfer any XRP remaining after the fee is paid to the destination:
auto const remainingBalance = src->getFieldAmount(sfBalance).xrp();
(*dst)[sfBalance] = (*dst)[sfBalance] + remainingBalance;
(*src)[sfBalance] = (*src)[sfBalance] - remainingBalance;
ctx_.deliver(remainingBalance);
if (src->isFieldPresent(sfSponsor))
{
auto const sponsorID = src->getAccountID(sfSponsor);
auto sponsorSle = view().peek(keylet::account(sponsorID));
if (!sponsorSle)
return tefINTERNAL; // LCOV_EXCL_LINE
auto const sponsoringAccountCount = sponsorSle->getFieldU32(sfSponsoringAccountCount);
XRPL_ASSERT(
sponsoringAccountCount != 0,
"xrpl::AccountDelete::doApply : sponsoring account count is present");
if (sponsoringAccountCount == 0)
{
// sanity check
// Since sfSponsoringAccountCount is set to soeDEFAULT, the field will not be
// present with a value of 0.
return tefINTERNAL; // LCOV_EXCL_LINE
}
sponsorSle->at(sfSponsoringAccountCount) = sponsoringAccountCount - 1;
view().update(sponsorSle);
// Following line might look redundant, but without it, sfSponsor
// would end up remaining in after-ltAccountRoot during the
// InvariantCheck.
src->makeFieldAbsent(sfSponsor);
}
XRPL_ASSERT(
(*src)[sfBalance] == XRPAmount(0), "xrpl::AccountDelete::doApply : source balance is zero");
// If there's still an owner directory associated with the source account
// delete it.
if (view().exists(ownerDirKeylet) && !view().emptyDirDelete(ownerDirKeylet))
{
JLOG(j_.error()) << "AccountDelete cannot delete root dir node of " << toBase58(accountID_);
return tecHAS_OBLIGATIONS;
}
// Re-arm the password change fee if we can and need to.
if (remainingBalance > XRPAmount(0) && dst->isFlag(lsfPasswordSpent))
dst->clearFlag(lsfPasswordSpent);
view().update(dst);
view().erase(src);
return tesSUCCESS;
return applyAccountDelete(ctx_, src, dst, j_);
}
void

View File

@@ -164,50 +164,6 @@ SponsorshipSet::preclaim(PreclaimContext const& ctx)
return tesSUCCESS;
}
static TER
deleteSponsorship(ApplyView& view, SLE::ref sle, beast::Journal j)
{
if (!sle)
return tecINTERNAL; // LCOV_EXCL_LINE
auto const sponsorID = (*sle)[sfOwner];
auto const sponseeID = (*sle)[sfSponsee];
// The sponsor owns the Sponsorship object, so deletion releases the
// sponsor's owner reserve.
auto sponsorAccSle = view.peek(keylet::account(sponsorID));
if (!sponsorAccSle)
return tecINTERNAL; // LCOV_EXCL_LINE
if (!view.dirRemove(keylet::ownerDir(sponsorID), (*sle)[sfOwnerNode], sle->key(), false))
{
// LCOV_EXCL_START
JLOG(j.fatal()) << "Unable to delete Sponsorship from sponsor.";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
if (!view.dirRemove(keylet::ownerDir(sponseeID), (*sle)[sfSponseeNode], sle->key(), false))
{
// LCOV_EXCL_START
JLOG(j.fatal()) << "Unable to delete Sponsorship from sponsee.";
return tefBAD_LEDGER;
// LCOV_EXCL_STOP
}
decreaseOwnerCountForObject(view, sponsorAccSle, sle, 1, j);
// Return any prefunded fee amount to the sponsor before erasing the object.
if (sle->isFieldPresent(sfFeeAmount))
{
(*sponsorAccSle)[sfBalance] += sle->getFieldAmount(sfFeeAmount);
view.update(sponsorAccSle);
}
view.erase(sle);
return tesSUCCESS;
}
TER
SponsorshipSet::doApply()
{
@@ -232,7 +188,7 @@ SponsorshipSet::doApply()
if (!sponsorshipSle)
return tecINTERNAL; // LCOV_EXCL_LINE
return deleteSponsorship(ctx_.view(), sponsorshipSle, ctx_.journal);
return deleteSponsorshipObject(ctx_.view(), sponsorshipSle, ctx_.journal);
}
auto const feeAmount = ctx_.tx[~sfFeeAmount];

View File

@@ -14,6 +14,7 @@
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/transactors/account/AccountDelete.h>
#include <bit>
#include <cstdint>
@@ -95,11 +96,22 @@ SponsorshipTransfer::getFlagsMask(PreflightContext const& ctx)
return tfSponsorshipTransferMask;
}
XRPAmount
SponsorshipTransfer::calculateBaseFee(ReadView const& view, STTx const& tx)
{
// Reaping deletes an account, so it costs one owner reserve like
// AccountDelete to keep account churn uneconomical. Other transfer flags
// use the normal base fee.
if (tx.isFlag(tfSponsorshipReap))
return calculateOwnerReserveFee(view, tx);
return Transactor::calculateBaseFee(view, tx);
}
NotTEC
SponsorshipTransfer::preflight(PreflightContext const& ctx)
{
static constexpr auto transferFlags =
tfSponsorshipCreate | tfSponsorshipReassign | tfSponsorshipEnd;
tfSponsorshipCreate | tfSponsorshipReassign | tfSponsorshipEnd | tfSponsorshipReap;
if (std::popcount(ctx.tx.getFlags() & transferFlags) != 1)
{
JLOG(ctx.j.debug()) << "preflight: Only one SponsorshipTransfer flag can be set per tx.";
@@ -190,6 +202,37 @@ SponsorshipTransfer::preflight(PreflightContext const& ctx)
}
}
if (ctx.tx.isFlag(tfSponsorshipReap))
{
// Reaping deletes an abandoned, under-reserved sponsored account. The
// sponsee is the target and the reaping sponsor is the tx sender, so no
// sfSponsor is supplied, the target must be a named account other than
// the sender, and no object may be specified (account-level only).
if (ctx.tx.isFieldPresent(sfSponsor))
{
JLOG(ctx.j.debug()) << "preflight: sfSponsor must not be present when reaping";
return temMALFORMED;
}
if (ctx.tx.isFieldPresent(sfObjectID))
{
JLOG(ctx.j.debug()) << "preflight: sfObjectID must not be present when reaping";
return temMALFORMED;
}
if (!ctx.tx.isFieldPresent(sfSponsee))
{
JLOG(ctx.j.debug()) << "preflight: sfSponsee must be present when reaping";
return temMALFORMED;
}
if (ctx.tx.getAccountID(sfSponsee) == ctx.tx.getAccountID(sfAccount))
{
JLOG(ctx.j.debug()) << "preflight: sfSponsee should not be the same as the account";
return temMALFORMED;
}
}
// Account-level reserve sponsorship changes the reserve responsibility for
// the account itself, so the new sponsor must explicitly co-sign. Object-level
// sponsorship may use pre-funded reserve sponsorship instead.
@@ -290,6 +333,37 @@ SponsorshipTransfer::preclaim(PreclaimContext const& ctx)
if (account != sponsor && account != sponseeID)
return tecNO_PERMISSION;
}
else if (ctx.tx.isFlag(tfSponsorshipReap))
{
// Reaping introduces no new sponsor and is account-level only.
if (newSponsorSle || objectID.has_value())
return tecNO_PERMISSION;
// The target must be an account-level sponsored account.
if (!isSponsored)
return tecNO_PERMISSION;
// Only the account's current sponsor may reap it.
auto const sponsor = targetSle->getAccountID(*sponsorField);
if (account != sponsor)
return tecNO_SPONSOR_PERMISSION;
// Reaping is limited to a sponsee that cannot stand on its own reserve.
// A solvent sponsee is never reapable — the sponsor should release the
// sponsorship with tfSponsorshipEnd instead. Keeping its own reserve is
// the sponsee's defense against being reaped.
XRPAmount const balance = sponseeSle->getFieldAmount(sfBalance).xrp();
XRPAmount const ownReserve =
accountReserve(ctx.view, sponseeSle, ctx.j, {.accountCountDelta = 1});
if (balance >= ownReserve)
return tecNO_PERMISSION;
// The sponsee account must itself satisfy the deletion preconditions
// (no obligations, outside the sequence-replay window).
if (auto const ter = checkAccountDeletable(ctx.view, sponseeSle, sponsor, ctx.j);
!isTesSuccess(ter))
return ter;
}
return tesSUCCESS;
}
@@ -518,6 +592,21 @@ SponsorshipTransfer::doApply()
!isTesSuccess(ter))
return ter; // LCOV_EXCL_LINE
}
else if (ctx_.tx.isFlag(tfSponsorshipReap))
{
// Reap the abandoned sponsee: delete its account, returning its
// residual XRP (and any prefunded sponsorship) to the sponsor. The
// account deletion unwinds the sfSponsor reserve sponsorship, so
// the sponsor's SponsoringAccountCount is released here.
auto const sponsorID = sponseeSle->getAccountID(sfSponsor);
auto sponsorSle = view().peek(keylet::account(sponsorID));
if (!sponsorSle)
return tefINTERNAL; // LCOV_EXCL_LINE
if (auto const ter = applyAccountDelete(ctx_, sponseeSle, sponsorSle, ctx_.journal);
!isTesSuccess(ter))
return ter;
}
}
return tesSUCCESS;

View File

@@ -4368,27 +4368,60 @@ public:
Account const sponsor("sponsor");
{
// Delete Sponsor/Sponsee Account with ltSponsorship (tecHAS_OBLIGATIONS)
// The sponsee may delete its account even while a Sponsorship
// object exists: the object is torn down and its prefunded fee is
// refunded to the sponsor.
Env env{*this, testableAmendments()};
env.fund(XRP(1000000), alice, bob, sponsor);
env.close();
// set sponsor
// prefunded Sponsorship object between sponsor and alice
env(sponsor::set(sponsor, 0, 100, XRP(100)),
sponsor::SponseeAcc(alice),
Ter(tesSUCCESS));
env.close();
incLgrSeqForAccDel(env, sponsor);
auto const keylet = keylet::sponsorship(sponsor, alice);
BEAST_EXPECT(env.le(keylet));
incLgrSeqForAccDel(env, alice);
auto const requiredFee = drops(env.current()->fees().increment);
auto const sponsorBalBefore = env.balance(sponsor);
env(acctdelete(alice, bob), Fee(requiredFee), Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(!env.le(keylet::account(alice)));
BEAST_EXPECT(!env.le(keylet)); // Sponsorship object removed
// Prefunded fee refunded to the sponsor.
BEAST_EXPECT(env.balance(sponsor) == sponsorBalBefore + XRP(100));
}
{
// The sponsor that owns the Sponsorship object may also delete its
// own account; the object is removed from both directories.
Env env{*this, testableAmendments()};
env.fund(XRP(1000000), alice, bob, sponsor);
env.close();
env(sponsor::set(sponsor, 0, 100, XRP(100)),
sponsor::SponseeAcc(alice),
Ter(tesSUCCESS));
env.close();
auto const keylet = keylet::sponsorship(sponsor, alice);
auto const sponsorObj = env.le(keylet);
BEAST_EXPECT(sponsorObj);
BEAST_EXPECT(env.le(keylet));
incLgrSeqForAccDel(env, sponsor);
// AccountDelete
auto const requiredFee = drops(env.current()->fees().increment);
env(acctdelete(alice, bob), Fee(requiredFee), Ter(tecHAS_OBLIGATIONS));
env(acctdelete(sponsor, bob), Fee(requiredFee), Ter(tecHAS_OBLIGATIONS));
env(acctdelete(sponsor, bob), Fee(requiredFee), Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(!env.le(keylet::account(sponsor)));
BEAST_EXPECT(!env.le(keylet)); // Sponsorship object removed
// alice remains, no longer linked to the removed object.
BEAST_EXPECT(env.le(keylet::account(alice)));
}
{
@@ -4527,6 +4560,231 @@ public:
}
}
void
testReap()
{
testcase("Reap");
using namespace test::jtx;
Account const alice("alice");
Account const bob("bob");
Account const sponsor("sponsor");
// preflight: malformed reap transactions
{
Env env{*this, testableAmendments()};
env.fund(XRP(10000), alice, bob, sponsor);
env.close();
// Missing sfSponsee.
env(sponsor::transfer(sponsor, tfSponsorshipReap), Ter(temMALFORMED));
// sfSponsee equal to the account.
env(sponsor::transfer(sponsor, tfSponsorshipReap),
sponsor::SponseeAcc(sponsor),
Ter(temMALFORMED));
// More than one transfer flag.
env(sponsor::transfer(sponsor, tfSponsorshipReap | tfSponsorshipEnd),
sponsor::SponseeAcc(alice),
Ter(temINVALID_FLAG));
}
// Reap succeeds on an abandoned, under-reserved sponsored account.
{
Env env{*this, testableAmendments()};
env.memoize(alice);
env.fund(XRP(1000000), bob, sponsor);
env.close();
// Create alice as a sponsored account holding a single drop.
env(pay(sponsor, alice, drops(1)), Txflags(tfSponsorCreatedAccount), Fee(XRP(1)));
env.close();
auto const aliceSle = env.le(keylet::account(alice));
BEAST_EXPECT(aliceSle && aliceSle->getAccountID(sfSponsor) == sponsor.id());
BEAST_EXPECT(sponsoringAccountCount(env, sponsor) == 1);
// Cannot reap until the sponsee clears the sequence-replay window.
env(sponsor::transfer(sponsor, tfSponsorshipReap),
sponsor::SponseeAcc(alice),
Fee(drops(env.current()->fees().increment)),
Ter(tecTOO_SOON));
incLgrSeqForAccDel(env, alice);
auto const reapFee = drops(env.current()->fees().increment);
auto const sponsorBalBefore = env.balance(sponsor);
env(sponsor::transfer(sponsor, tfSponsorshipReap),
sponsor::SponseeAcc(alice),
Fee(reapFee),
Ter(tesSUCCESS));
env.close();
// The account is gone and the sponsor's reserve is released.
BEAST_EXPECT(!env.le(keylet::account(alice)));
auto const sponsorSle = env.le(keylet::account(sponsor));
BEAST_EXPECT(!sponsorSle->isFieldPresent(sfSponsoringAccountCount));
// The sponsor recovers the sponsee's residual drop, less the fee.
BEAST_EXPECT(env.balance(sponsor) == sponsorBalBefore - reapFee + drops(1));
}
// A solvent sponsee can never be reaped — keeping its own reserve is
// the sponsee's defense.
{
Env env{*this, testableAmendments()};
env.memoize(alice);
env.fund(XRP(1000000), bob, sponsor);
env.close();
env(pay(sponsor, alice, XRP(10000)), Txflags(tfSponsorCreatedAccount));
env.close();
incLgrSeqForAccDel(env, alice);
env(sponsor::transfer(sponsor, tfSponsorshipReap),
sponsor::SponseeAcc(alice),
Fee(drops(env.current()->fees().increment)),
Ter(tecNO_PERMISSION));
// The account is untouched and still sponsored.
auto const aliceSle = env.le(keylet::account(alice));
BEAST_EXPECT(aliceSle && aliceSle->getAccountID(sfSponsor) == sponsor.id());
BEAST_EXPECT(sponsoringAccountCount(env, sponsor) == 1);
}
// Only the account's current sponsor may reap it.
{
Env env{*this, testableAmendments()};
env.memoize(alice);
env.fund(XRP(1000000), bob, sponsor);
env.close();
env(pay(sponsor, alice, drops(1)), Txflags(tfSponsorCreatedAccount), Fee(XRP(1)));
env.close();
incLgrSeqForAccDel(env, alice);
// bob is not alice's sponsor.
env(sponsor::transfer(bob, tfSponsorshipReap),
sponsor::SponseeAcc(alice),
Fee(drops(env.current()->fees().increment)),
Ter(tecNO_SPONSOR_PERMISSION));
BEAST_EXPECT(env.le(keylet::account(alice)));
}
// Reap also tears down a prefunded Sponsorship object for the sponsee,
// refunding its fee to the sponsor.
{
Env env{*this, testableAmendments()};
env.memoize(alice);
env.fund(XRP(1000000), bob, sponsor);
env.close();
env(pay(sponsor, alice, drops(1)), Txflags(tfSponsorCreatedAccount), Fee(XRP(1)));
env.close();
// Prefund a Sponsorship object for the sponsee.
env(sponsor::set(sponsor, 0, 100, XRP(100)),
sponsor::SponseeAcc(alice),
Ter(tesSUCCESS));
env.close();
auto const sponsorship = keylet::sponsorship(sponsor, alice);
BEAST_EXPECT(env.le(sponsorship));
incLgrSeqForAccDel(env, alice);
auto const reapFee = drops(env.current()->fees().increment);
auto const sponsorBalBefore = env.balance(sponsor);
env(sponsor::transfer(sponsor, tfSponsorshipReap),
sponsor::SponseeAcc(alice),
Fee(reapFee),
Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(!env.le(keylet::account(alice)));
BEAST_EXPECT(!env.le(sponsorship)); // Sponsorship object removed
auto const sponsorSle = env.le(keylet::account(sponsor));
BEAST_EXPECT(!sponsorSle->isFieldPresent(sfSponsoringAccountCount));
// Sponsor recovers the prefunded fee plus the residual drop.
BEAST_EXPECT(env.balance(sponsor) == sponsorBalBefore - reapFee + XRP(100) + drops(1));
}
// With featureSponsor disabled the transaction type is unavailable.
{
Env env{*this, testableAmendments() - featureSponsor};
env.fund(XRP(10000), alice, bob, sponsor);
env.close();
env(sponsor::transfer(sponsor, tfSponsorshipReap),
sponsor::SponseeAcc(alice),
Ter(temDISABLED));
}
// Reap is account-level only; supplying an object id is malformed.
{
Env env{*this, testableAmendments()};
env.fund(XRP(1000000), alice, bob, sponsor);
env.close();
env(sponsor::transfer(sponsor, tfSponsorshipReap, keylet::account(alice).key),
sponsor::SponseeAcc(alice),
Ter(temMALFORMED));
}
// A target that is not sponsored cannot be reaped.
{
Env env{*this, testableAmendments()};
env.fund(XRP(1000000), alice, bob, sponsor);
env.close();
incLgrSeqForAccDel(env, alice);
env(sponsor::transfer(sponsor, tfSponsorshipReap),
sponsor::SponseeAcc(alice),
Fee(drops(env.current()->fees().increment)),
Ter(tecNO_PERMISSION));
BEAST_EXPECT(env.le(keylet::account(alice)));
}
// Reaping a sponsee that does not exist returns terNO_ACCOUNT.
{
Env env{*this, testableAmendments()};
env.memoize(alice); // never funded
env.fund(XRP(1000000), bob, sponsor);
env.close();
env(sponsor::transfer(sponsor, tfSponsorshipReap),
sponsor::SponseeAcc(alice),
Fee(drops(env.current()->fees().increment)),
Ter(terNO_ACCOUNT));
}
// A sponsor cannot pull back its reserve on an under-reserved sponsee
// without deleting it: tfSponsorshipEnd is rejected, so the account is
// never left as an unbacked, under-reserved zombie.
{
Env env{*this, testableAmendments()};
env.memoize(alice);
env.fund(XRP(1000000), bob, sponsor);
env.close();
env(pay(sponsor, alice, drops(1)), Txflags(tfSponsorCreatedAccount), Fee(XRP(1)));
env.close();
env(sponsor::transfer(sponsor, tfSponsorshipEnd),
sponsor::SponseeAcc(alice),
Ter(tecINSUFFICIENT_RESERVE));
// Still sponsored — the reserve was not pulled back.
auto const aliceSle = env.le(keylet::account(alice));
BEAST_EXPECT(aliceSle && aliceSle->getAccountID(sfSponsor) == sponsor.id());
BEAST_EXPECT(sponsoringAccountCount(env, sponsor) == 1);
}
}
void
testDelegatePermission()
{
@@ -5453,6 +5711,7 @@ protected:
testSponsorAccount();
testAccountDelete();
testReap();
testDelegatePermission();
testDelegateBlockReserveSponsor();