test: Address review feedback on Sponsor tests

- Add accountSet() jtx helper and use it (plus existing helpers:
  sponsor::As, Oracle, MPTSet, xrpLiquid/accountReserve) instead of
  hand-built JSON where a suitable helper already exists
- Add missing coverage: equality at the count() maxU32 boundary,
  sb2-side assertions in PaymentSandbox owner-count merge tests,
  sequential fee/reserve sponsorship exhaustion across transactions,
  reserve-sponsored queue exhaustion in TxQ, and indirect deletion of
  an expired sponsored credential
- Drop the redundant self-compare in OwnerCounts equality test
- Move testFeeOnlySponsorshipObjectRouting next to
  testCosignedTransferConsumesPrefundedBudget (same precedence rule)
- Add missing check.h include in TxQ_test.cpp
This commit is contained in:
Mayukha Vadari
2026-09-01 16:27:43 -04:00
parent c27ae3c7d4
commit 4b697f819f
8 changed files with 281 additions and 65 deletions

View File

@@ -45,6 +45,7 @@
#include <xrpl/json/json_value.h>
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/OpenView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Asset.h>
#include <xrpl/protocol/Feature.h>
@@ -982,6 +983,78 @@ public:
}
}
void
testSequentialSponsorshipExhaustion()
{
// A prefunded Sponsorship object's FeeAmount/RemainingOwnerCount is
// shared across however many sponsored transactions the sponsee
// submits over time, not just a single one. Submit a run of
// transactions against a budget that only covers a few of them, and
// confirm the early ones succeed while the ones submitted once the
// budget is drained fail, without corrupting the object.
testcase("Sequential sponsorship exhaustion across transactions");
using namespace test::jtx;
Account const alice("alice");
Account const bob("bob");
Account const sponsor("sponsor");
// Fee exhaustion: enough FeeAmount for 3 of 6 fee-sponsored
// transactions; RemainingOwnerCount is generous so reserve is never
// the limiting factor.
{
Env env{*this, testableAmendments()};
env.fund(XRP(10000), alice, bob, sponsor);
env.close();
env(sponsor::set(sponsor, 0, 10, XRP(3)), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env.close();
for (int i = 1; i <= 6; ++i)
{
auto const expectedTer = i <= 3 ? tesSUCCESS : TER{terINSUF_FEE_B};
env(check::create(alice, bob, XRP(1)),
sponsor::As(sponsor, spfSponsorFee),
Sig(sfSponsorSignature, sponsor),
Fee(XRP(1)),
Ter(expectedTer));
env.close();
}
auto const sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle);
BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount));
BEAST_EXPECT(ownerCount(env, alice) == 3);
}
// Reserve exhaustion: enough RemainingOwnerCount for 3 of 6
// reserve-sponsored transactions; FeeAmount is generous so fee is
// never the limiting factor.
{
Env env{*this, testableAmendments()};
env.fund(XRP(10000), alice, bob, sponsor);
env.close();
env(sponsor::set(sponsor, 0, 3, XRP(100)), sponsor::SponseeAcc(alice), Ter(tesSUCCESS));
env.close();
for (int i = 1; i <= 6; ++i)
{
auto const expectedTer = i <= 3 ? tesSUCCESS : TER{tecINSUFFICIENT_RESERVE};
env(check::create(alice, bob, XRP(1)),
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, sponsor),
Fee(XRP(1)),
Ter(expectedTer));
env.close();
}
auto const sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle);
BEAST_EXPECT(sle->at(sfRemainingOwnerCount) == 0);
BEAST_EXPECT(ownerCount(env, alice) == 3);
}
}
void
testSponsoredFreeTierReserve()
{
@@ -6030,6 +6103,50 @@ public:
BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 2);
}
void
testFeeOnlySponsorshipObjectRouting()
{
// "Object always wins" fee routing: getFeePayer prefers an existing
// Sponsorship object over the co-signed sponsor's own balance. A
// fee-only-empty object (created by set_reserve, so sfFeeAmount is
// absent) therefore makes fee sponsorship fail with terINSUF_FEE_B
// even when the sponsor validly co-signs and could easily pay from
// its own balance. This is the intended precedence: once a
// Sponsorship object exists, all fee sponsorship for that
// sponsor/sponsee pair is routed through its pre-funded FeeAmount.
testcase("Fee-only Sponsorship object outranks a valid co-sign");
using namespace test::jtx;
Env env{*this, testableAmendments()};
Account const alice("alice");
Account const sponsor("sponsor");
env.fund(XRP(10000), alice, sponsor);
env.close();
// Reserve-only sponsorship: no sfFeeAmount on the object.
env(sponsor::set_reserve(sponsor, 0, 5), sponsor::SponseeAcc(alice));
env.close();
auto sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle && !sle->isFieldPresent(sfFeeAmount));
auto const aliceBalance = env.balance(alice);
auto const sponsorBalance = env.balance(sponsor);
env(noop(alice),
Fee(env.current()->fees().base),
sponsor::As(sponsor, spfSponsorFee),
Sig(sfSponsorSignature, sponsor),
Ter(terINSUF_FEE_B));
// Nothing was charged anywhere and the object is untouched.
BEAST_EXPECT(env.balance(alice) == aliceBalance);
BEAST_EXPECT(env.balance(sponsor) == sponsorBalance);
sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle && !sle->isFieldPresent(sfFeeAmount));
BEAST_EXPECT(sle && sle->getFieldU32(sfRemainingOwnerCount) == 5);
}
void
testZeroValueUpdateOnAbsentFields()
{
@@ -6103,56 +6220,14 @@ public:
env.fund(XRP(10000), alice, sponsor);
env.close();
auto tx = noop(alice);
tx[sfSponsor.jsonName] = sponsor.human();
tx[sfSponsorFlags.jsonName] = static_cast<std::uint32_t>(spfSponsorFee);
tx[sfSponsorSignature.jsonName][sfSigningPubKey.jsonName] = "";
env(tx, Fee(XRP(1)), Ter(telENV_RPC_FAILED));
}
void
testFeeOnlySponsorshipObjectRouting()
{
// "Object always wins" fee routing: getFeePayer prefers an existing
// Sponsorship object over the co-signed sponsor's own balance. A
// fee-only-empty object (created by set_reserve, so sfFeeAmount is
// absent) therefore makes fee sponsorship fail with terINSUF_FEE_B
// even when the sponsor validly co-signs and could easily pay from
// its own balance. This is the intended precedence: once a
// Sponsorship object exists, all fee sponsorship for that
// sponsor/sponsee pair is routed through its pre-funded FeeAmount.
testcase("Fee-only Sponsorship object outranks a valid co-sign");
using namespace test::jtx;
Env env{*this, testableAmendments()};
Account const alice("alice");
Account const sponsor("sponsor");
env.fund(XRP(10000), alice, sponsor);
env.close();
// Reserve-only sponsorship: no sfFeeAmount on the object.
env(sponsor::set_reserve(sponsor, 0, 5), sponsor::SponseeAcc(alice));
env.close();
auto sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle && !sle->isFieldPresent(sfFeeAmount));
auto const aliceBalance = env.balance(alice);
auto const sponsorBalance = env.balance(sponsor);
env(noop(alice),
Fee(env.current()->fees().base),
env(
noop(alice),
sponsor::As(sponsor, spfSponsorFee),
Sig(sfSponsorSignature, sponsor),
Ter(terINSUF_FEE_B));
// Nothing was charged anywhere and the object is untouched.
BEAST_EXPECT(env.balance(alice) == aliceBalance);
BEAST_EXPECT(env.balance(sponsor) == sponsorBalance);
sle = env.le(keylet::sponsorship(sponsor, alice));
BEAST_EXPECT(sle && !sle->isFieldPresent(sfFeeAmount));
BEAST_EXPECT(sle && sle->getFieldU32(sfRemainingOwnerCount) == 5);
[](Env&, JTx& jt) {
jt.jv[sfSponsorSignature.jsonName][sfSigningPubKey.jsonName] = "";
},
Fee(XRP(1)),
Ter(telENV_RPC_FAILED));
}
void
@@ -6273,10 +6348,19 @@ public:
// Control: for the unsponsored account, xrpLiquid = balance - base
// reserve = 0, so OfferCreate's funding check fails.
beast::Journal const j{beast::Journal::getNullSink()};
BEAST_EXPECT(accountReserve(*env.current(), bob.id(), j) == fees.accountReserve(0, 1));
BEAST_EXPECT(xrpLiquid(*env.current(), bob.id(), 0, j) == XRPAmount(0));
env(offer(bob, usd(5), XRP(5)), Ter(tecUNFUNDED_OFFER));
env.close();
BEAST_EXPECT(ownerCount(env, bob) == 0);
// The sponsored account contributes 0 to its own reserve, so its
// reserve is 0 (no owned objects yet), and its full balance is
// liquid.
BEAST_EXPECT(accountReserve(*env.current(), alice.id(), j) == fees.accountReserve(0, 0));
BEAST_EXPECT(xrpLiquid(*env.current(), alice.id(), 0, j) == env.balance(alice));
// The sponsored account's reserve is just the owner increment, so the
// same offer is funded and can even be placed on the book.
auto const offerSeq = env.seq(alice);
@@ -6436,6 +6520,63 @@ public:
}
}
void
testExpiredSponsoredCredentialIndirectDeletion()
{
// CredentialAccept on an already-expired sponsored credential fails
// with tecEXPIRED, but still deletes the credential as a side effect
// (removeExpired). That indirect deletion must still refund the
// sponsor's SponsoringOwnerCount and the issuer's SponsoredOwnerCount,
// just like the third-party EscrowCancel and payment-engine
// trust-line deletion cases above.
testcase("Indirect deletion of an expired sponsored credential");
using namespace test::jtx;
Env env{*this, testableAmendments()};
Account const alice("alice");
Account const bob("bob");
Account const sponsor("sponsor");
env.fund(XRP(10000), alice, bob, sponsor);
env.close();
auto const credType = std::string("test");
// alice (issuer) creates a credential for bob (subject), with a
// short expiration.
auto const expiration =
env.current()->header().parentCloseTime.time_since_epoch().count() + 20;
auto jv = credentials::create(bob, alice, credType);
jv[sfExpiration.jsonName] = expiration;
env(jv);
env.close();
auto const credKeylet = credentials::keylet(bob, alice, credType);
env(sponsor::transfer(alice, tfSponsorshipCreate, credKeylet.key),
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, sponsor));
env.close();
BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1);
BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1);
// Advance past expiration.
for (; env.current()->header().parentCloseTime.time_since_epoch().count() <=
static_cast<std::int64_t>(expiration);
env.close())
{
}
// Bob's accept fails (expired), but removeExpired deletes the
// credential as a side effect.
env(credentials::accept(bob, alice, credType), Ter(tecEXPIRED));
env.close();
BEAST_EXPECT(!env.le(credKeylet));
BEAST_EXPECT(ownerCount(env, alice) == 0);
BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0);
BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0);
}
void
testReserveSponsorAllowListNoOp()
{
@@ -6500,10 +6641,7 @@ public:
// MPTokenIssuanceSet
MPTTester mptt(env, alice, {.fund = false});
mptt.create({.flags = tfMPTCanLock});
json::Value jvSet;
jvSet[sfTransactionType.jsonName] = jss::MPTokenIssuanceSet;
jvSet[sfAccount.jsonName] = alice.human();
jvSet[sfMPTokenIssuanceID.jsonName] = to_string(mptt.issuanceID());
json::Value jvSet = mptt.setJV({.account = alice, .id = mptt.issuanceID()});
jvSet[sfFlags.jsonName] = tfMPTLock;
env(jvSet,
sponsor::As(sponsor, spfSponsorReserve),
@@ -6612,6 +6750,7 @@ protected:
testSimpleSponsorshipSet();
testPreFundAndCosign();
testSequentialSponsorshipExhaustion();
testSponsoredFreeTierReserve();
testSponsoredTicketUse();
@@ -6641,13 +6780,14 @@ protected:
testAccountSponsorshipTransferPermissions();
testCosignedTransferConsumesPrefundedBudget();
testFeeOnlySponsorshipObjectRouting();
testZeroValueUpdateOnAbsentFields();
testEmptySponsorSignatureObject();
testFeeOnlySponsorshipObjectRouting();
testOuterMultisignedSponsoredTx();
testSponsoredAccountBaseReserveExclusion();
testThirdPartyEscrowCancelRefundsSponsor();
testPaymentEngineTrustLineDeletion();
testExpiredSponsoredCredentialIndirectDeletion();
testReserveSponsorAllowListNoOp();
testPseudoAccountAsSponsor();
testCosignedClosedLedgerFeeCap();

View File

@@ -5,6 +5,7 @@
#include <test/jtx/WSClient.h>
#include <test/jtx/amount.h>
#include <test/jtx/balance.h>
#include <test/jtx/check.h>
#include <test/jtx/delegate.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/fee.h>
@@ -2420,6 +2421,60 @@ public:
BEAST_EXPECT(sponsorSle->at(~sfSponsoringOwnerCount) == 1);
}
void
testSponsorReserveQueueExhaustion()
{
using namespace jtx;
testcase("reserve-sponsored queue exhaustion across transactions");
Env env(*this, makeConfig({{Keys::kMinimumTxnInLedgerStandalone, "3"}}));
auto sponsor = Account("sponsor");
auto sponsee = Account("sponsee");
auto filler = Account("filler");
env.fund(XRP(50000), noripple(sponsor, sponsee));
env.close();
env.fund(XRP(50000), noripple(filler));
env.close();
// Prefund the sponsor's reserve budget for exactly 2 sponsored
// objects.
env(sponsor::set_reserve(sponsor, 0, 2), sponsor::SponseeAcc(sponsee));
env.close();
fillQueue(env, filler);
checkMetrics(*this, env, 0, 6, 4, 3);
// Queue 4 reserve-sponsored Check::create transactions from the same
// sponsee. Queuing only preclaims against the sponsorship's current
// (not-yet-consumed) budget, so all 4 queue successfully.
auto queued = Ter(terQUEUED);
auto const sponseeSeq = env.seq(sponsee);
for (int i = 0; i < 4; ++i)
{
env(check::create(sponsee, filler, XRP(10)),
sponsor::As(sponsor, spfSponsorReserve),
Seq(sponseeSeq + i),
queued);
}
checkMetrics(*this, env, 4, 6, 4, 3);
// Once the ledger closes, the queued transactions apply in order:
// only the first 2 succeed, since the sponsor's RemainingOwnerCount
// is exhausted by the time the 3rd and 4th are reached.
env.close();
BEAST_EXPECT(env.le(keylet::check(sponsee, sponseeSeq)));
BEAST_EXPECT(env.le(keylet::check(sponsee, sponseeSeq + 1)));
BEAST_EXPECT(!env.le(keylet::check(sponsee, sponseeSeq + 2)));
BEAST_EXPECT(!env.le(keylet::check(sponsee, sponseeSeq + 3)));
auto const sponsorSle = env.le(keylet::account(sponsor));
if (BEAST_EXPECT(sponsorSle))
BEAST_EXPECT(sponsorSle->at(~sfSponsoringOwnerCount) == 2);
}
void
testSponsorPrefundedTxCannotQueue()
{
@@ -4824,6 +4879,7 @@ public:
testInFlightBalance();
testSponsorTxCannotQueue();
testSponsorReserveTxCanQueue();
testSponsorReserveQueueExhaustion();
testSponsorPrefundedTxCannotQueue();
testDelegateTxCannotQueue();
testConsequences();

View File

@@ -97,6 +97,12 @@ namespace test::jtx {
// JSON generators
/**
* Build a bare AccountSet transaction, with no flags set.
*/
json::Value
accountSet(Account const& account);
/**
* Add and/or remove flag.
*/

View File

@@ -11,6 +11,15 @@
namespace xrpl::test::jtx {
json::Value
accountSet(Account const& account)
{
json::Value jv;
jv[jss::Account] = account.human();
jv[jss::TransactionType] = jss::AccountSet;
return jv;
}
json::Value
fset(Account const& account, std::uint32_t on, std::uint32_t off)
{

View File

@@ -113,8 +113,6 @@ class OwnerCounts_test : public beast::unit_test::Suite
testcase("equality");
auto const a = makeCounts(3, 1, 2);
auto const& self = a;
BEAST_EXPECT(a == self); // self-compare
BEAST_EXPECT(a == makeCounts(3, 1, 2)); // all fields equal
BEAST_EXPECT(a != makeCounts(4, 1, 2)); // differing owner
BEAST_EXPECT(a != makeCounts(3, 2, 2)); // differing sponsored
@@ -123,6 +121,12 @@ class OwnerCounts_test : public beast::unit_test::Suite
// equality; the fields themselves must match
BEAST_EXPECT(a.count() == makeCounts(4, 0, 0).count());
BEAST_EXPECT(a != makeCounts(4, 0, 0));
// Equality at the count() saturation boundary: same clamped
// count() (both maxU32) is not enough; fields must still match
constexpr auto maxU32 = std::numeric_limits<std::uint32_t>::max();
BEAST_EXPECT(makeCounts(maxU32, 0, 1) == makeCounts(maxU32, 0, 1));
BEAST_EXPECT(makeCounts(maxU32, 0, 1) != makeCounts(maxU32, 0, 2));
}
void

View File

@@ -519,6 +519,8 @@ class PaymentSandbox_test : public beast::unit_test::Suite
PaymentSandbox sb2(&sb);
sb2.adjustOwnerCountHook(alice, OwnerCounts(), counts);
// The child already holds the new value
BEAST_EXPECT(sb2.ownerCountHook(alice, OwnerCounts()) == counts);
// The parent has no entry for alice yet
BEAST_EXPECT(sb.ownerCountHook(alice, OwnerCounts()) == OwnerCounts());
@@ -558,6 +560,10 @@ class PaymentSandbox_test : public beast::unit_test::Suite
{
PaymentSandbox sb2(&sb);
sb2.adjustOwnerCountHook(bob, OwnerCounts(), lower);
// The child's view already reflects the parent's higher
// inherited value; adjusting with a lower one doesn't
// regress it.
BEAST_EXPECT(sb2.ownerCountHook(bob, OwnerCounts()) == higher);
sb2.apply(sb);
}
BEAST_EXPECT(sb.ownerCountHook(bob, OwnerCounts()) == higher);

View File

@@ -6,6 +6,7 @@
#include <test/jtx/batch.h>
#include <test/jtx/envconfig.h>
#include <test/jtx/fee.h>
#include <test/jtx/flags.h>
#include <test/jtx/multisign.h>
#include <test/jtx/noop.h>
#include <test/jtx/pay.h>
@@ -2753,9 +2754,7 @@ public:
// signs; SponsorSignature and Signers are not signing fields, so
// later sign_for calls don't invalidate the sponsor's signature.
auto setupTx = [&]() {
json::Value tx;
tx[jss::Account] = alice.human();
tx[jss::TransactionType] = jss::AccountSet;
json::Value tx = accountSet(alice);
tx[jss::Fee] = (3 * baseFee).jsonClipped();
tx[jss::Sequence] = env.seq(alice);
tx[jss::SigningPubKey] = "";

View File

@@ -399,9 +399,7 @@ class Simulate_test : public beast::unit_test::Suite
{
// Signed SponsorSignature (non-empty TxnSignature)
json::Value params;
json::Value txJson = json::ValueType::Object;
txJson[jss::TransactionType] = jss::AccountSet;
txJson[jss::Account] = env.master.human();
json::Value txJson = accountSet(env.master);
json::Value sponsorSignature = json::ValueType::Object;
sponsorSignature[jss::TxnSignature] = "1200ABCD";
txJson[sfSponsorSignature] = sponsorSignature;
@@ -1031,9 +1029,7 @@ class Simulate_test : public beast::unit_test::Suite
}
};
json::Value tx;
tx[jss::Account] = alice.human();
tx[jss::TransactionType] = jss::AccountSet;
json::Value tx = accountSet(alice);
tx[sfDomain] = "123ABC";
tx[sfSponsor.jsonName] = sponsor.human();
tx[sfSponsorFlags.jsonName] = spfSponsorFee;