diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index cf07a8e13f..80e2488b3d 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -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(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(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(); diff --git a/src/test/app/TxQ_test.cpp b/src/test/app/TxQ_test.cpp index 32a5d83923..85d87243fb 100644 --- a/src/test/app/TxQ_test.cpp +++ b/src/test/app/TxQ_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -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(); diff --git a/src/test/jtx/flags.h b/src/test/jtx/flags.h index 5bbe3c8d12..8f5e454a99 100644 --- a/src/test/jtx/flags.h +++ b/src/test/jtx/flags.h @@ -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. */ diff --git a/src/test/jtx/impl/flags.cpp b/src/test/jtx/impl/flags.cpp index ad49f559f3..436acb59ef 100644 --- a/src/test/jtx/impl/flags.cpp +++ b/src/test/jtx/impl/flags.cpp @@ -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) { diff --git a/src/test/ledger/OwnerCounts_test.cpp b/src/test/ledger/OwnerCounts_test.cpp index 86e65c1aee..65bef9b137 100644 --- a/src/test/ledger/OwnerCounts_test.cpp +++ b/src/test/ledger/OwnerCounts_test.cpp @@ -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::max(); + BEAST_EXPECT(makeCounts(maxU32, 0, 1) == makeCounts(maxU32, 0, 1)); + BEAST_EXPECT(makeCounts(maxU32, 0, 1) != makeCounts(maxU32, 0, 2)); } void diff --git a/src/test/ledger/PaymentSandbox_test.cpp b/src/test/ledger/PaymentSandbox_test.cpp index 731acecdbe..6b08815149 100644 --- a/src/test/ledger/PaymentSandbox_test.cpp +++ b/src/test/ledger/PaymentSandbox_test.cpp @@ -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); diff --git a/src/test/rpc/JSONRPC_test.cpp b/src/test/rpc/JSONRPC_test.cpp index 4e1fe3b182..aca18a5f48 100644 --- a/src/test/rpc/JSONRPC_test.cpp +++ b/src/test/rpc/JSONRPC_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -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] = ""; diff --git a/src/test/rpc/Simulate_test.cpp b/src/test/rpc/Simulate_test.cpp index 4618f87deb..41364d2bf7 100644 --- a/src/test/rpc/Simulate_test.cpp +++ b/src/test/rpc/Simulate_test.cpp @@ -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;