fix: Switch SponsorshipSet to use a delta for sfFeeAmount

This commit is contained in:
Mayukha Vadari
2026-07-29 14:24:55 -04:00
committed by Ayaz Salikhov
parent e290005db5
commit 24b6dad287
10 changed files with 522 additions and 234 deletions

View File

@@ -3,13 +3,16 @@
#include <xrpl/basics/Log.h>
#include <xrpl/beast/utility/Journal.h>
#include <xrpl/beast/utility/Zero.h>
#include <xrpl/beast/utility/instrumentation.h>
#include <xrpl/core/ServiceRegistry.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/ReadView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
#include <xrpl/protocol/LedgerFormats.h>
#include <xrpl/protocol/SField.h>
#include <xrpl/protocol/STAmount.h>
@@ -17,36 +20,62 @@
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/tx/Transactor.h>
#include <algorithm>
#include <cstdint>
#include <limits>
#include <memory>
#include <optional>
namespace xrpl {
// Compute the resulting RemainingOwnerCount using signed 64-bit arithmetic to
// avoid unsigned wraparound. A missing SLE (object creation) or absent field
// counts as zero. Callers handle the out-of-range results: a negative value is
// clamped to zero (field absent) and overflow is rejected in preclaim.
static std::int64_t
totalRemainingOwnerCount(
SLE::const_ref sponsorshipSle,
std::optional<std::int32_t> const& remainingOwnerCountDelta)
{
std::uint32_t const currentCount =
sponsorshipSle ? (*sponsorshipSle)[~sfRemainingOwnerCount].value_or(0u) : 0u;
return static_cast<std::int64_t>(currentCount) + remainingOwnerCountDelta.value_or(0);
}
static bool
hasSponsorshipBudget(
SLE::const_ref sponsorshipSle,
std::optional<STAmount> const& feeAmount,
std::optional<std::uint32_t> const& remainingOwnerCount)
std::optional<STAmount> const& feeAmountDelta,
std::optional<std::int32_t> const& remainingOwnerCountDelta)
{
// A field the transaction omits keeps whatever the existing object holds,
// sfFeeAmountDelta and sfRemainingOwnerCountDelta must be non-negative when creating a new
// Sponsorship object.
if (!sponsorshipSle)
{
if (feeAmountDelta.has_value() && *feeAmountDelta <= beast::kZero)
return false;
if (remainingOwnerCountDelta.has_value() && *remainingOwnerCountDelta <= 0)
return false;
}
// If the transaction omits a field, it keeps whatever the existing object holds,
// so fall back to the current SLE value when the tx does not set it.
bool const hasFeeAmount = feeAmount
? *feeAmount > beast::kZero
: sponsorshipSle && (*sponsorshipSle)[~sfFeeAmount].value_or(STAmount{0}) > beast::kZero;
STAmount const currentFee =
sponsorshipSle ? (*sponsorshipSle)[~sfFeeAmount].value_or(STAmount{0}) : STAmount{0};
STAmount const newFee = currentFee + feeAmountDelta.value_or(STAmount{0});
bool const hasRemainingOwnerCount = remainingOwnerCount
? *remainingOwnerCount > 0
: sponsorshipSle && (*sponsorshipSle)[~sfRemainingOwnerCount].value_or(0) > 0;
std::int64_t const newCount =
totalRemainingOwnerCount(sponsorshipSle, remainingOwnerCountDelta);
return hasFeeAmount || hasRemainingOwnerCount;
return newFee > beast::kZero || newCount > 0;
}
TxConsequences
SponsorshipSet::makeTxConsequences(PreflightContext const& ctx)
{
auto const feeAmount = ctx.tx[~sfFeeAmount];
return TxConsequences{ctx.tx, feeAmount.has_value() ? feeAmount->xrp() : beast::kZero};
auto const feeAmount = ctx.tx[~sfFeeAmountDelta];
auto const feeAmountDelta = std::max(STAmount{0}, feeAmount.value_or(STAmount{0}));
return TxConsequences{ctx.tx, feeAmountDelta.xrp()};
}
std::uint32_t
@@ -90,8 +119,8 @@ SponsorshipSet::preflight(PreflightContext const& ctx)
return temINVALID_FLAG;
// Transactions deleting `Sponsorship` cannot include modification fields.
if (ctx.tx.isFieldPresent(sfFeeAmount) || ctx.tx.isFieldPresent(sfRemainingOwnerCount) ||
ctx.tx.isFieldPresent(sfMaxFee))
if (ctx.tx.isFieldPresent(sfFeeAmountDelta) ||
ctx.tx.isFieldPresent(sfRemainingOwnerCountDelta) || ctx.tx.isFieldPresent(sfMaxFee))
return temMALFORMED;
}
else
@@ -101,27 +130,26 @@ SponsorshipSet::preflight(PreflightContext const& ctx)
if (account != sponsorID)
return temMALFORMED;
// FeeAmount and MaxFee must be non-negative XRP amounts when present.
auto const checkOptionalAmountField = [&](SField const& field) -> NotTEC {
if (!ctx.tx.isFieldPresent(field))
return tesSUCCESS;
// FeeAmountDelta must be a non-zero XRP amount when present.
if (auto const feeAmt = ctx.tx[~sfFeeAmountDelta];
feeAmt && (!isXRP(*feeAmt) || *feeAmt == beast::kZero))
return temBAD_AMOUNT;
auto const amount = ctx.tx.getFieldAmount(field);
// MaxFee must be a non-negative XRP amount when present.
if (auto const maxFee = ctx.tx[~sfMaxFee];
maxFee && (!isXRP(*maxFee) || *maxFee < beast::kZero))
return temBAD_AMOUNT;
if (!isXRP(amount))
return temBAD_AMOUNT;
// RemainingOwnerCountDelta must be a non-zero integer when present.
if (auto const remainingOwnerCountDelta = ctx.tx[~sfRemainingOwnerCountDelta];
remainingOwnerCountDelta && *remainingOwnerCountDelta == 0)
return temINVALID;
if (amount.xrp() < beast::kZero)
return temBAD_AMOUNT;
return tesSUCCESS;
};
if (auto const ret = checkOptionalAmountField(sfFeeAmount); !isTesSuccess(ret))
return ret;
if (auto const ret = checkOptionalAmountField(sfMaxFee); !isTesSuccess(ret))
return ret;
// nothing specified in the tx
if (!ctx.tx.isFieldPresent(sfRemainingOwnerCountDelta) &&
!ctx.tx.isFieldPresent(sfFeeAmountDelta) && !ctx.tx.isFieldPresent(sfMaxFee) &&
((ctx.tx.getFlags() & tfUniversalMask) == 0))
return temREDUNDANT;
}
return tesSUCCESS;
@@ -154,12 +182,21 @@ SponsorshipSet::preclaim(PreclaimContext const& ctx)
if (ctx.tx.isFlag(tfDeleteObject) && !sponsorshipSle)
return tecNO_ENTRY;
// Reject creating or updating a Sponsorship that would be left with no
// budget (neither a positive FeeAmount nor a positive RemainingOwnerCount).
// Such an object is unusable yet still consumes the sponsor's reserve.
if (!ctx.tx.isFlag(tfDeleteObject) &&
!hasSponsorshipBudget(sponsorshipSle, ctx.tx[~sfFeeAmount], ctx.tx[~sfRemainingOwnerCount]))
return tecNO_PERMISSION;
if (!ctx.tx.isFlag(tfDeleteObject))
{
// Reject if applying the delta would overflow uint32_t. A negative delta
// that underflows is clamped to zero (field absent) rather than erroring.
if (totalRemainingOwnerCount(sponsorshipSle, ctx.tx[~sfRemainingOwnerCountDelta]) >
static_cast<std::int64_t>(std::numeric_limits<std::uint32_t>::max()))
return tecLIMIT_EXCEEDED;
// Reject creating or updating a Sponsorship that would be left with no
// budget (neither a positive FeeAmount nor a positive RemainingOwnerCount).
// Such an object is unusable yet still consumes the sponsor's reserve.
if (!hasSponsorshipBudget(
sponsorshipSle, ctx.tx[~sfFeeAmountDelta], ctx.tx[~sfRemainingOwnerCountDelta]))
return tecNO_PERMISSION;
}
return tesSUCCESS;
}
@@ -208,6 +245,91 @@ deleteSponsorship(ApplyView& view, SLE::ref sle, beast::Journal j)
return tesSUCCESS;
}
TER
SponsorshipSet::createSponsorship(
Keylet const& sponsorshipKeylet,
AccountID const& sponsorID,
AccountID const& sponseeID,
SLE::ref sponsorAccSle,
SLE::ref reserveSponsorAccSle)
{
auto const feeAmountDelta = ctx_.tx[~sfFeeAmountDelta];
auto const maxFee = ctx_.tx[~sfMaxFee];
auto const remainingOwnerCountDelta = ctx_.tx[~sfRemainingOwnerCountDelta];
bool const hasPositiveFeeAmount = feeAmountDelta.has_value() && *feeAmountDelta > beast::kZero;
// Create a new Sponsorship object between the sponsor and sponsee.
auto newSle = std::make_shared<SLE>(sponsorshipKeylet);
STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance];
// sfFeeAmountDelta must be positive if the sponsorship object doesn't exist. This is
// checked in preclaim.
XRPL_ASSERT(
!feeAmountDelta.has_value() || *feeAmountDelta > beast::kZero,
"xrpl::SponsorshipSet::doApply : new sponsorship has positive fee amount");
(*newSle)[sfOwner] = sponsorID;
(*newSle)[sfSponsee] = sponseeID;
if (feeAmountDelta && feeAmountDelta->xrp() > sponsorBalanceAfterFee.xrp())
return tecUNFUNDED;
if (hasPositiveFeeAmount)
sponsorBalanceAfterFee -= *feeAmountDelta;
if (auto const ret = checkReserve(
ctx_.getApplyViewContext(),
sponsorAccSle,
sponsorBalanceAfterFee.xrp(),
reserveSponsorAccSle,
{.ownerCountDelta = 1},
ctx_.journal,
tecUNFUNDED);
!isTesSuccess(ret))
{
return ret;
}
if (hasPositiveFeeAmount)
{
// New object: FeeAmount starts absent, so deduct and record the full amount
(*newSle)[sfFeeAmount] = *feeAmountDelta;
(*sponsorAccSle)[sfBalance] -= *feeAmountDelta;
}
if (maxFee && *maxFee > beast::kZero)
(*newSle)[sfMaxFee] = *maxFee;
if (remainingOwnerCountDelta && *remainingOwnerCountDelta > 0)
(*newSle)[sfRemainingOwnerCount] = *remainingOwnerCountDelta;
std::uint32_t flags = 0;
if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForFee))
flags |= lsfSponsorshipRequireSignForFee;
if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForReserve))
flags |= lsfSponsorshipRequireSignForReserve;
(*newSle)[sfFlags] = flags;
auto const sponsorPage = view().dirInsert(
keylet::ownerDir(sponsorID), sponsorshipKeylet, describeOwnerDir(sponsorID));
if (!sponsorPage)
return tecDIR_FULL; // LCOV_EXCL_LINE
(*newSle)[sfOwnerNode] = *sponsorPage;
auto const sponseePage = view().dirInsert(
keylet::ownerDir(sponseeID), sponsorshipKeylet, describeOwnerDir(sponseeID));
if (!sponseePage)
return tecDIR_FULL; // LCOV_EXCL_LINE
(*newSle)[sfSponseeNode] = *sponseePage;
// NOLINTNEXTLINE(readability-suspicious-call-argument)
increaseOwnerCount(view(), sponsorAccSle, reserveSponsorAccSle, 1, ctx_.journal);
addSponsorToLedgerEntry(newSle, reserveSponsorAccSle);
ctx_.view().insert(newSle);
return tesSUCCESS;
}
TER
SponsorshipSet::doApply()
{
@@ -224,8 +346,8 @@ SponsorshipSet::doApply()
if (!ctx_.view().exists(keylet::account(sponseeID)))
return tecINTERNAL; // LCOV_EXCL_LINE
auto const sponsorKeylet = keylet::sponsorship(sponsorID, sponseeID);
auto const sponsorshipSle = ctx_.view().peek(sponsorKeylet);
auto const sponsorshipKeylet = keylet::sponsorship(sponsorID, sponseeID);
auto const sponsorshipSle = ctx_.view().peek(sponsorshipKeylet);
if (ctx_.tx.isFlag(tfDeleteObject))
{
@@ -235,11 +357,9 @@ SponsorshipSet::doApply()
return deleteSponsorship(ctx_.view(), sponsorshipSle, ctx_.journal);
}
auto const feeAmount = ctx_.tx[~sfFeeAmount];
auto const feeAmountDelta = ctx_.tx[~sfFeeAmountDelta];
auto const maxFee = ctx_.tx[~sfMaxFee];
auto const remainingOwnerCount = ctx_.tx[~sfRemainingOwnerCount];
bool const hasPositiveFeeAmount = feeAmount.has_value() && *feeAmount > beast::kZero;
auto const remainingOwnerCountDelta = ctx_.tx[~sfRemainingOwnerCountDelta];
auto reserveSponsorAccSle = getTxReserveSponsor(ctx_.getApplyViewContext());
if (!reserveSponsorAccSle)
@@ -247,24 +367,33 @@ SponsorshipSet::doApply()
if (!sponsorshipSle)
{
// Create a new Sponsorship object between the sponsor and sponsee.
auto newSle = std::make_shared<SLE>(sponsorKeylet);
return createSponsorship(
sponsorshipKeylet, sponsorID, sponseeID, sponsorAccSle, *reserveSponsorAccSle);
}
(*newSle)[sfOwner] = sponsorID;
(*newSle)[sfSponsee] = sponseeID;
if (feeAmount && (*feeAmount).xrp() > (*sponsorAccSle)[sfBalance])
// Update the existing Sponsorship object.
if (feeAmountDelta)
{
auto actualDelta = feeAmountDelta.value();
auto const currentFee = (*sponsorshipSle)[~sfFeeAmount].valueOr(XRPAmount{0});
// Clamp negative delta to avoid underflow.
if (actualDelta < beast::kZero && -actualDelta > currentFee)
actualDelta = -currentFee;
// Reject if the sponsor cannot afford the (positive) delta.
if (actualDelta > beast::kZero && actualDelta > (*sponsorAccSle)[sfBalance])
return tecUNFUNDED;
STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance];
if (hasPositiveFeeAmount)
sponsorBalanceAfterFee -= *feeAmount;
// Move the FeeAmount delta between the sponsor balance and Sponsorship
// object.
(*sponsorAccSle)[sfBalance] -= actualDelta;
if (auto const ret = checkReserve(
ctx_.getApplyViewContext(),
sponsorAccSle,
sponsorBalanceAfterFee.xrp(),
(*sponsorAccSle)[sfBalance]->xrp(),
*reserveSponsorAccSle,
{.ownerCountDelta = 1},
{},
ctx_.journal,
tecUNFUNDED);
!isTesSuccess(ret))
@@ -272,87 +401,19 @@ SponsorshipSet::doApply()
return ret;
}
if (hasPositiveFeeAmount)
STAmount const newFee = currentFee + actualDelta;
// checked in preclaim
XRPL_ASSERT(
newFee >= beast::kZero, "xrpl::SponsorshipSet::doApply : new fee is non-negative");
if (newFee == beast::kZero)
{
// New object: FeeAmount starts absent, so deduct and record the full amount
(*newSle)[sfFeeAmount] = *feeAmount;
(*sponsorAccSle)[sfBalance] -= *feeAmount;
sponsorshipSle->makeFieldAbsent(sfFeeAmount);
}
if (maxFee && *maxFee > beast::kZero)
(*newSle)[sfMaxFee] = *maxFee;
if (remainingOwnerCount && *remainingOwnerCount > 0)
(*newSle)[sfRemainingOwnerCount] = *remainingOwnerCount;
std::uint32_t flags = 0;
if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForFee))
flags |= lsfSponsorshipRequireSignForFee;
if (ctx_.tx.isFlag(tfSponsorshipSetRequireSignForReserve))
flags |= lsfSponsorshipRequireSignForReserve;
(*newSle)[sfFlags] = flags;
auto const sponsorPage = view().dirInsert(
keylet::ownerDir(sponsorID), sponsorKeylet, describeOwnerDir(sponsorID));
if (!sponsorPage)
return tecDIR_FULL; // LCOV_EXCL_LINE
(*newSle)[sfOwnerNode] = *sponsorPage;
auto const sponseePage = view().dirInsert(
keylet::ownerDir(sponseeID), sponsorKeylet, describeOwnerDir(sponseeID));
if (!sponseePage)
return tecDIR_FULL; // LCOV_EXCL_LINE
(*newSle)[sfSponseeNode] = *sponseePage;
// NOLINTNEXTLINE(readability-suspicious-call-argument)
increaseOwnerCount(view(), sponsorAccSle, *reserveSponsorAccSle, 1, ctx_.journal);
addSponsorToLedgerEntry(newSle, *reserveSponsorAccSle);
ctx_.view().insert(newSle);
return tesSUCCESS;
}
// Update the existing Sponsorship object.
if (feeAmount)
{
auto const currentFeeAmount = (*sponsorshipSle)[~sfFeeAmount].valueOr(XRPAmount{0});
auto const feeAmountDelta = XRPAmount(*feeAmount - currentFeeAmount);
if (feeAmountDelta > beast::kZero && feeAmountDelta > (*sponsorAccSle)[sfBalance])
return tecUNFUNDED;
// Move the FeeAmount delta between the sponsor balance and Sponsorship
// object.
if (feeAmountDelta != beast::kZero)
else
{
STAmount sponsorBalanceAfterFee = (*sponsorAccSle)[sfBalance];
sponsorBalanceAfterFee -= feeAmountDelta;
if (auto const ret = checkReserve(
ctx_.getApplyViewContext(),
sponsorAccSle,
sponsorBalanceAfterFee.xrp(),
*reserveSponsorAccSle,
{},
ctx_.journal,
tecUNFUNDED);
!isTesSuccess(ret))
{
return ret;
}
(*sponsorAccSle)[sfBalance] -= feeAmountDelta;
if (*feeAmount == beast::kZero)
{
(*sponsorshipSle).makeFieldAbsent(sfFeeAmount);
}
else
{
(*sponsorshipSle).setFieldAmount(sfFeeAmount, *feeAmount);
}
ctx_.view().update(sponsorAccSle);
(*sponsorshipSle)[sfFeeAmount] = newFee;
}
ctx_.view().update(sponsorAccSle);
}
if (maxFee)
@@ -367,15 +428,21 @@ SponsorshipSet::doApply()
}
}
if (remainingOwnerCount)
if (remainingOwnerCountDelta)
{
if (*remainingOwnerCount == 0)
std::int64_t const newCount =
totalRemainingOwnerCount(sponsorshipSle, remainingOwnerCountDelta);
// Overflow is rejected in preclaim; underflow clamps to zero (field absent).
XRPL_ASSERT(
newCount <= static_cast<std::int64_t>(std::numeric_limits<std::uint32_t>::max()),
"xrpl::SponsorshipSet::doApply : RemainingOwnerCount does not overflow");
if (newCount <= 0)
{
sponsorshipSle->makeFieldAbsent(sfRemainingOwnerCount);
}
else
{
sponsorshipSle->at(sfRemainingOwnerCount) = *remainingOwnerCount;
sponsorshipSle->at(sfRemainingOwnerCount) = static_cast<std::uint32_t>(newCount);
}
}

View File

@@ -2116,8 +2116,8 @@ class MPToken_test : public beast::unit_test::Suite
jv[jss::TransactionType] = jss::SponsorshipSet;
jv[jss::Account] = alice.human();
jv[sfSponsee.fieldName] = carol.human();
jv[sfFeeAmount.fieldName] = mpt.getJson(JsonOptions::Values::None);
test(jv, sfFeeAmount.fieldName);
jv[sfFeeAmountDelta.fieldName] = mpt.getJson(JsonOptions::Values::None);
test(jv, sfFeeAmountDelta.fieldName);
}
}
BEAST_EXPECT(txWithAmounts.empty());

View File

@@ -58,6 +58,7 @@
#include <cstdint>
#include <functional>
#include <limits>
#include <memory>
#include <optional>
#include <set>
@@ -197,10 +198,12 @@ public:
sponsor::SponseeAcc(alice),
Ter(temMALFORMED));
// Invalid feeAmount
for (auto const& amt : {XRP(-1), usd(1)})
// Invalid FeeAmountDelta
for (auto const& amt : {XRP(0), usd(1)})
{
env(sponsor::set_fee(sponsor, 0, amt), sponsor::SponseeAcc(alice), Ter(temBAD_AMOUNT));
env(sponsor::set_fee(sponsor, 0, amt, XRP(1)),
sponsor::SponseeAcc(alice),
Ter(temBAD_AMOUNT));
}
// Invalid MaxFee
for (auto const& amt : {XRP(-1), usd(1)})
@@ -209,6 +212,10 @@ public:
sponsor::SponseeAcc(alice),
Ter(temBAD_AMOUNT));
}
// Invalid RemainingOwnerCountDelta
env(sponsor::set(sponsor, 0, 0, XRP(2), XRP(1)),
sponsor::SponseeAcc(alice),
Ter(temINVALID));
// Invalid Delete operation
env(sponsor::set_reserve(sponsor, tfDeleteObject, 1),
@@ -229,12 +236,15 @@ public:
sponsor::CounterpartySponsor(alice),
Ter(temMALFORMED));
// Redundant tx
env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(alice), Ter(temREDUNDANT));
//
// preclaim
//
// Invalid Sponsee
env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(noFunded), Ter(tecNO_DST));
env(sponsor::set(sponsor, 0, 1), sponsor::SponseeAcc(noFunded), Ter(tecNO_DST));
env.close();
// Invalid Sponsor
@@ -290,7 +300,7 @@ public:
// Decreasing feeAmount should succeed (refund, negative delta)
adjustAccountXRPBalance(env, sponsor, XRP(500));
env(sponsor::set_fee(sponsor, 0, XRP(800)),
env(sponsor::set_fee(sponsor, 0, XRP(-200)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tesSUCCESS));
@@ -299,7 +309,7 @@ public:
// Increasing feeAmount within delta budget should succeed
adjustAccountXRPBalance(env, sponsor, XRP(500));
env(sponsor::set_fee(sponsor, 0, XRP(850)),
env(sponsor::set_fee(sponsor, 0, XRP(50)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tesSUCCESS));
@@ -308,18 +318,15 @@ public:
// Increasing feeAmount where delta exceeds balance should fail
adjustAccountXRPBalance(env, sponsor, XRP(310));
env(sponsor::set_fee(sponsor, 0, XRP(1200)),
env(sponsor::set_fee(sponsor, 0, XRP(350)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tecUNFUNDED));
env.close();
// Increasing feeAmount to reach insufficient reserve
auto const currentFeeAmount = env.le(keylet::sponsorship(sponsor.id(), alice.id()))
->getFieldAmount(sfFeeAmount)
.xrp();
adjustAccountXRPBalance(env, sponsor, XRP(310));
env(sponsor::set_fee(sponsor, 0, currentFeeAmount + XRP(309)),
env(sponsor::set_fee(sponsor, 0, XRP(309)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tecUNFUNDED));
@@ -543,7 +550,7 @@ public:
BEAST_EXPECT(env.balance(sponsor) == XRP(10000) - sle->at(sfFeeAmount) - XRP(1));
// update sponsorship (decrement)
env(sponsor::set(sponsor, 0, 50, XRP(50), XRP(0.5)),
env(sponsor::set(sponsor, 0, -50, XRP(-50), XRP(0.5)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tesSUCCESS));
@@ -557,7 +564,7 @@ public:
BEAST_EXPECT(env.balance(sponsor) == XRP(10000) - sle->at(sfFeeAmount) - XRP(2));
// update sponsorship (increment)
env(sponsor::set(sponsor, 0, 200, XRP(200), XRP(2)),
env(sponsor::set(sponsor, 0, 150, XRP(150), XRP(2)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tesSUCCESS));
@@ -591,26 +598,32 @@ public:
env.close();
BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
// Cannot create sponsorship with no fee or reserve budget. MaxFee
// and flags do not make a sponsorship object useful by themselves.
env(sponsor::set(sponsor, 0), sponsor::SponseeAcc(alice), Ter(tecNO_PERMISSION));
env.close();
BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
env(sponsor::set_max_fee(sponsor, 0, XRP(1)),
sponsor::SponseeAcc(alice),
Ter(tecNO_PERMISSION));
env.close();
BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)),
env(sponsor::set(sponsor, 0, std::nullopt, std::nullopt, XRP(0)),
sponsor::SponseeAcc(alice),
Ter(tecNO_PERMISSION));
env.close();
BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
// update sponsorship with non-zero value
env(sponsor::set(sponsor, 0, 100, XRP(100), XRP(1)),
// create sponsorship with negative values
env(sponsor::set_reserve(sponsor, 0, -100),
sponsor::SponseeAcc(alice),
Ter(tecNO_PERMISSION));
env.close();
BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
env(sponsor::set_fee(sponsor, 0, XRP(-100)),
sponsor::SponseeAcc(alice),
Ter(tecNO_PERMISSION));
env.close();
BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice)));
// create sponsorship with non-zero value
env(sponsor::set(sponsor, 0, 100, XRP(101), XRP(1)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)));
env.close();
@@ -618,7 +631,7 @@ public:
sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle);
BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100);
BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100));
BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(101));
BEAST_EXPECT(sle->at(sfMaxFee) == XRP(1));
// update sponsorship flags
@@ -648,7 +661,7 @@ public:
lsfSponsorshipRequireSignForReserve);
// Cannot update sponsorship so both fee and reserve budgets are absent.
env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)),
env(sponsor::set(sponsor, 0, -100, XRP(-101), std::nullopt),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tecNO_PERMISSION));
@@ -657,17 +670,17 @@ public:
sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle);
BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100);
BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100));
BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(101));
BEAST_EXPECT(sle->at(sfMaxFee) == XRP(1));
}
{
// Removing one budget field while the other remains keeps the
// Sponsorship valid. Starting state (from above):
// RemainingOwnerCount = 100, FeeAmount = XRP(100).
// RemainingOwnerCount = 100, FeeAmount = XRP(101).
// Remove only FeeAmount (set to 0); RemainingOwnerCount remains.
env(sponsor::set_fee(sponsor, 0, XRP(0)),
env(sponsor::set_fee(sponsor, 0, XRP(-101)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tesSUCCESS));
@@ -686,12 +699,51 @@ public:
Ter(tesSUCCESS));
env.close();
env(sponsor::set_reserve(sponsor, 0, 0),
// A negative FeeAmountDelta larger than the current FeeAmount is
// clamped, so only the current FeeAmount is refunded and the field
// is removed. RemainingOwnerCount keeps the Sponsorship valid.
auto const balanceBefore = env.balance(sponsor);
env(sponsor::set_fee(sponsor, 0, XRP(-500)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tesSUCCESS));
env.close();
sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle);
BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount));
BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 100);
BEAST_EXPECT(env.balance(sponsor) == balanceBefore + XRP(100) - XRP(1));
// Restore FeeAmount for the checks below.
env(sponsor::set_fee(sponsor, 0, XRP(100)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tesSUCCESS));
env.close();
env(sponsor::set_reserve(sponsor, 0, -100),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tesSUCCESS));
env.close();
sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle);
BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount));
BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100));
// Decreasing FeeAmount below zero must fail with tecNO_PERMISSION
// when there is no RemainingOwnerCount (the budget would become
// entirely empty). Current state: FeeAmount = XRP(100), no
// RemainingOwnerCount.
env(sponsor::set_fee(sponsor, 0, XRP(-101)),
sponsor::SponseeAcc(alice),
Fee(XRP(1)),
Ter(tecNO_PERMISSION));
env.close();
// Confirm that the sponsorship is unchanged.
sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle);
BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount));
@@ -748,6 +800,160 @@ public:
}
}
void
testRemainingOwnerCountOverflow()
{
testcase("RemainingOwnerCount overflow and underflow clamping");
using namespace test::jtx;
Env env{*this, testableAmendments()};
Account const alice("alice");
Account const sponsor("sponsor");
env.fund(XRP(10000), alice, sponsor);
env.close();
constexpr std::int32_t kInt32Max = std::numeric_limits<std::int32_t>::max();
// --- Positive overflow: delta causes count to exceed UINT32_MAX ---
{
// Create with count = INT32_MAX.
env(sponsor::set_reserve(sponsor, 0, kInt32Max),
sponsor::SponseeAcc(alice),
Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(
env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) ==
static_cast<std::uint32_t>(kInt32Max));
// Add INT32_MAX again: count = 2 * INT32_MAX = 4294967294 (<= UINT32_MAX, still ok).
env(sponsor::set_reserve(sponsor, 0, kInt32Max),
sponsor::SponseeAcc(alice),
Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(
env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) ==
2u * static_cast<std::uint32_t>(kInt32Max));
// Adding 2 more pushes count to 4294967296, exceeding UINT32_MAX: reject.
env(sponsor::set_reserve(sponsor, 0, 2),
sponsor::SponseeAcc(alice),
Ter(tecLIMIT_EXCEEDED));
env.close();
// SLE is unchanged.
BEAST_EXPECT(
env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) ==
2u * static_cast<std::uint32_t>(kInt32Max));
env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env.close();
}
// --- Negative underflow: clamps to 0; fee budget survives ---
{
// Create with count=10 and a fee budget.
env(sponsor::set(sponsor, 0, 10, XRP(100)),
sponsor::SponseeAcc(alice),
Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(
env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u);
// Delta of -20 produces count = -10; clamps to 0 (field absent).
env(sponsor::set_reserve(sponsor, 0, -20), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env.close();
auto sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle);
BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount));
BEAST_EXPECT(sle->at(sfFeeAmount) == XRP(100));
env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env.close();
}
// --- Negative underflow: clamped count=0 with no fee budget → no budget ---
{
// Create with count=10, no fee.
env(sponsor::set_reserve(sponsor, 0, 10), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env.close();
BEAST_EXPECT(
env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u);
// Delta of -20 would clamp count to 0 with no fee → empty budget → tecNO_PERMISSION.
env(sponsor::set_reserve(sponsor, 0, -20),
sponsor::SponseeAcc(alice),
Ter(tecNO_PERMISSION));
env.close();
// SLE is unchanged.
BEAST_EXPECT(
env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10u);
env(sponsor::del(sponsor), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env.close();
}
}
void
testConsequences()
{
testcase("Consequences");
using namespace test::jtx;
Env env{*this, testableAmendments()};
auto const baseFee = env.current()->fees().base;
Account const alice("alice");
Account const sponsor("sponsor");
env.memoize(alice);
env.memoize(sponsor);
{
// A positive FeeAmountDelta is the maximum XRP the tx can spend.
auto const jt = env.jt(
sponsor::set_fee(sponsor, 0, XRP(100)),
sponsor::SponseeAcc(alice),
Seq(1),
Fee(baseFee));
auto const pf =
preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal);
BEAST_EXPECT(isTesSuccess(pf.ter));
BEAST_EXPECT(!pf.consequences.isBlocker());
BEAST_EXPECT(pf.consequences.fee() == drops(baseFee));
BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(100));
}
{
// A negative FeeAmountDelta withdraws from the sponsorship, so the
// transaction cannot spend anything.
auto const jt = env.jt(
sponsor::set_fee(sponsor, 0, XRP(-100)),
sponsor::SponseeAcc(alice),
Seq(1),
Fee(baseFee));
auto const pf =
preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal);
BEAST_EXPECT(isTesSuccess(pf.ter));
BEAST_EXPECT(!pf.consequences.isBlocker());
BEAST_EXPECT(pf.consequences.fee() == drops(baseFee));
BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0));
}
{
// No FeeAmountDelta at all.
auto const jt = env.jt(
sponsor::set_reserve(sponsor, 0, 10),
sponsor::SponseeAcc(alice),
Seq(1),
Fee(baseFee));
auto const pf =
preflight(env.app(), env.current()->rules(), *jt.stx, TapNone, env.journal);
BEAST_EXPECT(isTesSuccess(pf.ter));
BEAST_EXPECT(!pf.consequences.isBlocker());
BEAST_EXPECT(pf.consequences.fee() == drops(baseFee));
BEAST_EXPECT(pf.consequences.potentialSpend() == XRP(0));
}
}
void
testPreFundAndCosign()
{
@@ -810,7 +1016,7 @@ public:
Ter(terINSUF_FEE_B));
env.close();
env(sponsor::set_reserve(sponsor, 0, 0), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env(sponsor::set_reserve(sponsor, 0, -1), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env.close();
// reserve insufficient
@@ -2090,7 +2296,7 @@ public:
XRP(10));
// clear flag
env(sponsor::set_fee(sponsor, tfSponsorshipClearRequireSignForFee, XRP(10)),
env(sponsor::set(sponsor, tfSponsorshipClearRequireSignForFee),
sponsor::SponseeAcc(alice));
env.close();
@@ -2322,7 +2528,7 @@ public:
XRP(10));
// clear flag
env(sponsor::set_fee(sponsor, tfSponsorshipClearRequireSignForFee, XRP(10)),
env(sponsor::set(sponsor, tfSponsorshipClearRequireSignForFee),
sponsor::SponseeAcc(alice));
env.close();
@@ -4939,7 +5145,7 @@ public:
env.close();
// Create pre-funded sponsorship
env(sponsor::set(sponsor, 0, 0, XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1)));
env(sponsor::set_fee(sponsor, 0, XRP(1)), sponsor::SponseeAcc(alice), Fee(XRP(1)));
env.close();
auto const seq = env.seq(alice);
@@ -5443,6 +5649,8 @@ protected:
testInvalidSponsorField();
testSimpleSponsorshipSet();
testRemainingOwnerCountOverflow();
testConsequences();
testPreFundAndCosign();
testSponsoredFreeTierReserve();

View File

@@ -21,18 +21,18 @@ namespace xrpl::test::jtx::sponsor {
json::Value
set(jtx::Account const& account,
uint32_t flags,
std::optional<uint32_t> const reserveCount,
std::optional<STAmount> const feeAmount,
std::optional<int32_t> const reserveCountDelta,
std::optional<STAmount> const feeAmountDelta,
std::optional<STAmount> const maxFee)
{
json::Value jv;
jv[jss::TransactionType] = jss::SponsorshipSet;
jv[jss::Account] = account.human();
jv[sfFlags.jsonName] = flags;
if (reserveCount)
jv[sfRemainingOwnerCount.jsonName] = *reserveCount;
if (feeAmount)
jv[sfFeeAmount.jsonName] = feeAmount->getJson(JsonOptions::Values::None);
if (reserveCountDelta)
jv[sfRemainingOwnerCountDelta.jsonName] = *reserveCountDelta;
if (feeAmountDelta)
jv[sfFeeAmountDelta.jsonName] = feeAmountDelta->getJson(JsonOptions::Values::None);
if (maxFee)
jv[sfMaxFee.jsonName] = maxFee->getJson(JsonOptions::Values::None);
return jv;

View File

@@ -18,24 +18,24 @@ namespace xrpl::test::jtx::sponsor {
json::Value
set(jtx::Account const& account,
std::uint32_t flags,
std::optional<std::uint32_t> const reserveCount = std::nullopt,
std::optional<STAmount> const feeAmount = std::nullopt,
std::optional<std::int32_t> const reserveCountDelta = std::nullopt,
std::optional<STAmount> const feeAmountDelta = std::nullopt,
std::optional<STAmount> const maxFee = std::nullopt);
inline json::Value
set_fee(
jtx::Account const& account,
std::uint32_t flags,
STAmount feeAmount,
STAmount feeAmountDelta,
std::optional<STAmount> maxFee = std::nullopt)
{
return set(account, flags, std::nullopt, std::move(feeAmount), std::move(maxFee));
return set(account, flags, std::nullopt, std::move(feeAmountDelta), std::move(maxFee));
}
inline json::Value
set_reserve(jtx::Account const& account, std::uint32_t flags, std::uint32_t reserveCount)
set_reserve(jtx::Account const& account, std::uint32_t flags, std::int32_t reserveCountDelta)
{
return set(account, flags, reserveCount);
return set(account, flags, reserveCountDelta);
}
inline json::Value

View File

@@ -31,9 +31,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip)
// Transaction-specific field values
auto const counterpartySponsorValue = canonical_ACCOUNT();
auto const sponseeValue = canonical_ACCOUNT();
auto const feeAmountValue = canonical_AMOUNT();
auto const feeAmountDeltaValue = canonical_AMOUNT();
auto const maxFeeValue = canonical_AMOUNT();
auto const remainingOwnerCountValue = canonical_UINT32();
auto const remainingOwnerCountDeltaValue = canonical_INT32();
SponsorshipSetBuilder builder{
accountValue,
@@ -44,9 +44,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip)
// Set optional fields
builder.setCounterpartySponsor(counterpartySponsorValue);
builder.setSponsee(sponseeValue);
builder.setFeeAmount(feeAmountValue);
builder.setFeeAmountDelta(feeAmountDeltaValue);
builder.setMaxFee(maxFeeValue);
builder.setRemainingOwnerCount(remainingOwnerCountValue);
builder.setRemainingOwnerCountDelta(remainingOwnerCountDeltaValue);
auto tx = builder.build(publicKey, secretKey);
@@ -81,11 +81,11 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip)
}
{
auto const& expected = feeAmountValue;
auto const actualOpt = tx.getFeeAmount();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmount should be present";
expectEqualField(expected, *actualOpt, "sfFeeAmount");
EXPECT_TRUE(tx.hasFeeAmount());
auto const& expected = feeAmountDeltaValue;
auto const actualOpt = tx.getFeeAmountDelta();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmountDelta should be present";
expectEqualField(expected, *actualOpt, "sfFeeAmountDelta");
EXPECT_TRUE(tx.hasFeeAmountDelta());
}
{
@@ -97,11 +97,11 @@ TEST(TransactionsSponsorshipSetTests, BuilderSettersRoundTrip)
}
{
auto const& expected = remainingOwnerCountValue;
auto const actualOpt = tx.getRemainingOwnerCount();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCount should be present";
expectEqualField(expected, *actualOpt, "sfRemainingOwnerCount");
EXPECT_TRUE(tx.hasRemainingOwnerCount());
auto const& expected = remainingOwnerCountDeltaValue;
auto const actualOpt = tx.getRemainingOwnerCountDelta();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCountDelta should be present";
expectEqualField(expected, *actualOpt, "sfRemainingOwnerCountDelta");
EXPECT_TRUE(tx.hasRemainingOwnerCountDelta());
}
}
@@ -122,9 +122,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip)
// Transaction-specific field values
auto const counterpartySponsorValue = canonical_ACCOUNT();
auto const sponseeValue = canonical_ACCOUNT();
auto const feeAmountValue = canonical_AMOUNT();
auto const feeAmountDeltaValue = canonical_AMOUNT();
auto const maxFeeValue = canonical_AMOUNT();
auto const remainingOwnerCountValue = canonical_UINT32();
auto const remainingOwnerCountDeltaValue = canonical_INT32();
// Build an initial transaction
SponsorshipSetBuilder initialBuilder{
@@ -135,9 +135,9 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip)
initialBuilder.setCounterpartySponsor(counterpartySponsorValue);
initialBuilder.setSponsee(sponseeValue);
initialBuilder.setFeeAmount(feeAmountValue);
initialBuilder.setFeeAmountDelta(feeAmountDeltaValue);
initialBuilder.setMaxFee(maxFeeValue);
initialBuilder.setRemainingOwnerCount(remainingOwnerCountValue);
initialBuilder.setRemainingOwnerCountDelta(remainingOwnerCountDeltaValue);
auto initialTx = initialBuilder.build(publicKey, secretKey);
@@ -171,10 +171,10 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip)
}
{
auto const& expected = feeAmountValue;
auto const actualOpt = rebuiltTx.getFeeAmount();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmount should be present";
expectEqualField(expected, *actualOpt, "sfFeeAmount");
auto const& expected = feeAmountDeltaValue;
auto const actualOpt = rebuiltTx.getFeeAmountDelta();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfFeeAmountDelta should be present";
expectEqualField(expected, *actualOpt, "sfFeeAmountDelta");
}
{
@@ -185,10 +185,10 @@ TEST(TransactionsSponsorshipSetTests, BuilderFromStTxRoundTrip)
}
{
auto const& expected = remainingOwnerCountValue;
auto const actualOpt = rebuiltTx.getRemainingOwnerCount();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCount should be present";
expectEqualField(expected, *actualOpt, "sfRemainingOwnerCount");
auto const& expected = remainingOwnerCountDeltaValue;
auto const actualOpt = rebuiltTx.getRemainingOwnerCountDelta();
ASSERT_TRUE(actualOpt.has_value()) << "Optional field sfRemainingOwnerCountDelta should be present";
expectEqualField(expected, *actualOpt, "sfRemainingOwnerCountDelta");
}
}
@@ -250,12 +250,12 @@ TEST(TransactionsSponsorshipSetTests, OptionalFieldsReturnNullopt)
EXPECT_FALSE(tx.getCounterpartySponsor().has_value());
EXPECT_FALSE(tx.hasSponsee());
EXPECT_FALSE(tx.getSponsee().has_value());
EXPECT_FALSE(tx.hasFeeAmount());
EXPECT_FALSE(tx.getFeeAmount().has_value());
EXPECT_FALSE(tx.hasFeeAmountDelta());
EXPECT_FALSE(tx.getFeeAmountDelta().has_value());
EXPECT_FALSE(tx.hasMaxFee());
EXPECT_FALSE(tx.getMaxFee().has_value());
EXPECT_FALSE(tx.hasRemainingOwnerCount());
EXPECT_FALSE(tx.getRemainingOwnerCount().has_value());
EXPECT_FALSE(tx.hasRemainingOwnerCountDelta());
EXPECT_FALSE(tx.getRemainingOwnerCountDelta().has_value());
}
}