From f234682ec3ca62790d624f41eeca49b09f7687e2 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Thu, 9 Jul 2026 19:30:26 -0400 Subject: [PATCH 1/3] add a boatload of tests (AI) --- src/test/app/Invariants_test.cpp | 135 +++ src/test/app/Sponsor_test.cpp | 1208 ++++++++++++++++++++++- src/test/app/TxQ_test.cpp | 88 ++ src/test/ledger/OwnerCounts_test.cpp | 188 ++++ src/test/ledger/PaymentSandbox_test.cpp | 101 ++ src/test/rpc/AccountObjects_test.cpp | 43 + src/test/rpc/JSONRPC_test.cpp | 141 +++ src/test/rpc/Simulate_test.cpp | 142 +++ 8 files changed, 2021 insertions(+), 25 deletions(-) create mode 100644 src/test/ledger/OwnerCounts_test.cpp diff --git a/src/test/app/Invariants_test.cpp b/src/test/app/Invariants_test.cpp index 9c90ade72f1..d2565d6076f 100644 --- a/src/test/app/Invariants_test.cpp +++ b/src/test/app/Invariants_test.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -5769,6 +5771,139 @@ class Invariants_test : public beast::unit_test::Suite return true; }); } + + { + // XRPNotCreated: the sfFeeAmount escrowed in a Sponsorship entry + // counts toward the XRP total. Creating a Sponsorship holding a + // FeeAmount without debiting any account manufactures XRP. + doInvariantCheck( + {{"XRP net change was positive: 100000000"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sleNew = + std::make_shared(keylet::sponsorship(a1.id(), a2.id())); + sleNew->setAccountID(sfOwner, a1.id()); + sleNew->setAccountID(sfSponsee, a2.id()); + sleNew->setFieldAmount(sfFeeAmount, XRP(100)); + ac.view().insert(sleNew); + return true; + }); + + // ... and deleting a Sponsorship entry without refunding its + // escrowed FeeAmount destroys XRP beyond the transaction fee. + doInvariantCheck( + {{"XRP net change of -100000000 doesn't match fee 0"}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const sle = ac.view().peek(keylet::sponsorship(a2.id(), a1.id())); + if (!sle) + return false; + ac.view().erase(sle); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [](Account const& a1, Account const& a2, Env& env) { + // a2 sponsors a1's fees with a pre-funded XRP(100) + env(sponsor::set_fee(a2, 0, XRP(100)), sponsor::SponseeAcc(a1)); + env.close(); + return env.le(keylet::sponsorship(a2.id(), a1.id())) != nullptr; + }); + } + + { + // Deleting a sponsored object without adjusting the accounts' + // Sponsored/SponsoringOwnerCount fields must be caught (the + // isDelete path of SponsorshipOwnerCountsMatch::visitEntry: the + // sponsored-object delta is -1 while the account deltas are 0). + auto const expectMessage = + "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta."; + uint256 checkID; + + doInvariantCheck( + {{expectMessage}}, + [&](Account const&, Account const&, ApplyContext& ac) { + auto const check = ac.view().peek(keylet::check(checkID)); + if (!check) + return false; + ac.view().erase(check); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + [&checkID](Account const& a1, Account const& a2, Env& env) { + // a2 reserve-sponsors a1's check by co-signing its creation + checkID = keylet::check(a1.id(), env.seq(a1)).key; + env(check::create(a1, a2, XRP(1)), + sponsor::As(a2, spfSponsorReserve), + Sig(sfSponsorSignature, a2), + Fee(XRP(1))); + env.close(); + auto const sle = env.le(keylet::check(checkID)); + return sle && sle->isFieldPresent(sfSponsor); + }); + } + + { + // Trust lines use sfLowSponsor/sfHighSponsor instead of sfSponsor + // and can be reserve-sponsored independently on each side. + auto const expectMessage = + "SponsoredObjectOwnerCount does not equal SponsoredOwnerCount delta."; + + auto const preclose = [](Account const& a1, Account const& a2, Env& env) { + env(trust(a1, a2["USD"](100))); + return true; + }; + + // Marking one side of an existing trust line as sponsored without + // touching any account counts (object delta 1, account deltas 0). + doInvariantCheck( + {{expectMessage}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const line = ac.view().peek(keylet::trustLine(a1, a2, a2["USD"].currency)); + if (!line) + return false; + line->setAccountID(sfLowSponsor, a2.id()); + ac.view().update(line); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + preclose); + + // A trust line sponsored on both sides contributes two sponsored + // owner counts, so bumping the account fields by only one each is + // caught (object delta 2, account deltas 1). + doInvariantCheck( + {{expectMessage}}, + [](Account const& a1, Account const& a2, ApplyContext& ac) { + auto const line = ac.view().peek(keylet::trustLine(a1, a2, a2["USD"].currency)); + if (!line) + return false; + line->setAccountID(sfLowSponsor, a2.id()); + line->setAccountID(sfHighSponsor, a2.id()); + ac.view().update(line); + + // a1 owns the trust line, so its OwnerCount is already 1. + auto const sponsee = ac.view().peek(keylet::account(a1.id())); + if (!sponsee) + return false; + sponsee->setFieldU32(sfSponsoredOwnerCount, 1); + ac.view().update(sponsee); + + auto const sponsoring = ac.view().peek(keylet::account(a2.id())); + if (!sponsoring) + return false; + sponsoring->setFieldU32(sfSponsoringOwnerCount, 1); + ac.view().update(sponsoring); + return true; + }, + XRPAmount{}, + STTx{ttACCOUNT_SET, [](STObject&) {}}, + {tecINVARIANT_FAILED, tefINVARIANT_FAILED}, + preclose); + } } void diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index f20aac68f90..90002ab0b2c 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -19,6 +20,8 @@ #include #include #include +#include +#include #include #include #include @@ -29,6 +32,7 @@ #include #include #include +#include #include #include @@ -185,6 +189,8 @@ class Sponsor_test : public beast::unit_test::Suite } // invalid SponsorAccount / Sponsee + // Neither Sponsor nor Sponsee is specified (amendment enabled) + env(sponsor::set(sponsor, 0), Ter(temMALFORMED)); // Account = Sponsor env(sponsor::set(alice, tfDeleteObject), sponsor::CounterpartySponsor(alice), @@ -374,10 +380,13 @@ class Sponsor_test : public beast::unit_test::Suite using namespace test::jtx; Env env{*this, testableAmendments()}; Account const alice("alice"); + Account const bob("bob"); Account const sponsor("sponsor"); Account const invalid("invalid"); + Account const regular("regular"); - env.fund(XRP(10000), alice, sponsor); + env.fund(XRP(10000), alice, bob, sponsor); + env.memoize(regular); env.close(); // Signature doesn't exist @@ -404,6 +413,63 @@ class Sponsor_test : public beast::unit_test::Suite sponsor::As(sponsor, spfSponsorReserve), Sig(sfSponsorSignature, sponsor), Ter(tesSUCCESS)); + env.close(); + + // The SponsorSignature is a cryptographically valid signature, but it + // is bob's, not the named sponsor's. It passes the local crypto-only + // check (STTx::checkSign verifies the signature against its own + // SigningPubKey), and the engine then rejects it in + // Transactor::checkSingleSign ("signed with any other key") with + // tefBAD_AUTH. + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, bob), + Ter(tefBAD_AUTH)); + + // Give the sponsor a regular key. + env(regkey(sponsor, regular)); + env.close(); + + // A sponsor signature made with the regular key is accepted + // (Transactor::checkSingleSign: RegularKey == idSigner). + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, regular), + Ter(tesSUCCESS)); + env.close(); + + // The master key still works while it is enabled. + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + + // Disable the sponsor's master key. Sig(sponsor) forces the master + // key: with a regular key set, Env::autofill_sig signs with the + // regular key, and AccountSet would return tecNEED_MASTER_KEY. + env(fset(sponsor, asfDisableMaster), Sig(sponsor)); + env.close(); + + // A master-key sponsor signature is now rejected + // (Transactor::checkSingleSign: lsfDisableMaster -> + // tefMASTER_DISABLED). + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tefMASTER_DISABLED)); + + // ... but the regular key still works. + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, regular), + Ter(tesSUCCESS)); + env.close(); } void @@ -462,6 +528,99 @@ class Sponsor_test : public beast::unit_test::Suite sponsor::As(sponsor, spfSponsorReserve), Msig(sfSponsorSignature, {signer1, signer2}), Ter(tesSUCCESS)); + env.close(); + + // Below quorum: Transactor::checkMultiSign -> tefBAD_QUORUM. + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorReserve), + Msig(sfSponsorSignature, {signer1}), + Ter(tefBAD_QUORUM)); + + // A signer that is not on the sponsor's signer list: + // Transactor::checkMultiSign ("The SigningAccount is not in the + // SignerEntries") -> tefBAD_SIGNATURE. + Account const outsider("outsider"); + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorReserve), + Msig(sfSponsorSignature, {signer1, outsider}), + Ter(tefBAD_SIGNATURE)); + + // Both the sponsee transaction and the sponsor are multisigned. The + // required fee is + // (1 + |tx.Signers| + |SponsorSignature.Signers|) * baseFee + // (Transactor::calculateBaseFee). + env(signers(alice, 1, {{signer1, 1}, {signer2, 1}})); + env.close(); + + env(noop(alice), + Msig({signer1}), + sponsor::As(sponsor, spfSponsorReserve), + Msig(sfSponsorSignature, {signer1, signer2}), + Fee(baseFee + 3 * baseFee - 1), + Ter(telINSUF_FEE_P)); + + env(noop(alice), + Msig({signer1}), + sponsor::As(sponsor, spfSponsorReserve), + Msig(sfSponsorSignature, {signer1, signer2}), + Fee(baseFee + 3 * baseFee), + Ter(tesSUCCESS)); + env.close(); + } + + void + testSponsoredTicketUse() + { + testcase("Sponsored transaction consuming a Ticket"); + 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(); + + std::uint32_t const ticketSeq{env.seq(alice) + 1}; + env(ticket::create(alice, 2)); + env.close(); + env.require(tickets(alice, 2)); + BEAST_EXPECT(ownerCount(env, alice) == 2); + + auto const aliceSeq = env.seq(alice); + + // Fee-sponsored (co-signed) transaction consuming a ticket. + auto const feeAmt = XRP(1); + auto const sponsorBalance = env.balance(sponsor); + env(noop(alice), + ticket::Use(ticketSeq), + Fee(feeAmt), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + + env.require(tickets(alice, 1)); + BEAST_EXPECT(env.balance(sponsor) == sponsorBalance - feeAmt); + // Consuming a ticket does not advance the account sequence. + BEAST_EXPECT(env.seq(alice) == aliceSeq); + + // Reserve-sponsored (co-signed) transaction consuming a ticket. + env(check::create(alice, bob, XRP(10)), + ticket::Use(ticketSeq + 1), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + + env.require(tickets(alice, 0)); + BEAST_EXPECT(env.seq(alice) == aliceSeq); + // The consumed ticket is replaced by the sponsored check. + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); } void @@ -2330,6 +2489,84 @@ class Sponsor_test : public beast::unit_test::Suite BEAST_EXPECT(ownerCount(env, alice) == 1); BEAST_EXPECT(!env.le(keylet::sponsorship(sponsor, alice))->isFieldPresent(sfFeeAmount)); } + + { + testcase("RequireSign flags with prefunded SponsorshipTransfer"); + + 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(); + + // alice owns an unsponsored check. + auto const checkSeq = env.seq(alice); + env(check::create(alice, bob, XRP(1))); + env.close(); + auto const checkId = keylet::check(alice, checkSeq).key; + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // Sponsorship with a reserve budget where prefunded (unsigned) use + // of the reserve budget requires a sponsor signature. + env(sponsor::set_reserve(sponsor, tfSponsorshipSetRequireSignForReserve, 10), + sponsor::SponseeAcc(alice)); + env.close(); + + // An unsigned SponsorshipTransfer(tfSponsorshipCreate) is rejected + // by Transactor::checkSponsor (lsfSponsorshipRequireSignForReserve). + env(sponsor::transfer(alice, tfSponsorshipCreate, checkId), + sponsor::As(sponsor, spfSponsorReserve), + Ter(terNO_PERMISSION)); + env.close(); + + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 10); + + // With a sponsor signature the transfer succeeds and + // SponsorshipTransfer::doApply still draws down the prefunded + // budget (decrementPrefundedReserveCount). + env(sponsor::transfer(alice, tfSponsorshipCreate, checkId), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + auto const checkSle = env.le(keylet::check(alice, checkSeq)); + BEAST_EXPECT( + checkSle && checkSle->isFieldPresent(sfSponsor) && + checkSle->getAccountID(sfSponsor) == sponsor.id()); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->at(sfRemainingOwnerCount) == 9); + + // Mirror for the fee flag on an ordinary prefunded fee-sponsored + // transaction: unsigned use is rejected by Transactor::checkSponsor + // (lsfSponsorshipRequireSignForFee), and a co-signed transaction + // still draws the fee from the prefunded FeeAmount because + // getFeePayer prefers the Sponsorship object when it exists. + env(sponsor::set_fee(sponsor, tfSponsorshipSetRequireSignForFee, XRP(10)), + sponsor::SponseeAcc(alice)); + env.close(); + + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorFee), + Ter(terNO_PERMISSION)); + env.close(); + BEAST_EXPECT(sponsor::sponsorshipFeeBalance(env, sponsor, alice) == XRP(10)); + + env(noop(alice), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + BEAST_EXPECT(sponsor::sponsorshipFeeBalance(env, sponsor, alice) == XRP(9)); + } } void @@ -4525,6 +4762,134 @@ class Sponsor_test : public beast::unit_test::Suite BEAST_EXPECT(sponsorSle->at(sfSponsoringOwnerCount) == 0); } } + + testcase("Fee-sponsored AccountDelete"); + + { + // Co-signed: the sponsor pays the special owner-reserve fee + // (AccountDelete::calculateBaseFee = calculateOwnerReserveFee = + // fees().increment) from its own balance, floored at its own + // reserve (Transactor::checkFee / payFee for + // FeePayerType::SponsorCoSigned). + Env env{*this, testableAmendments()}; + env.fund(XRP(10000), alice, bob, sponsor); + env.close(); + + incLgrSeqForAccDel(env, alice); + + STAmount const requiredFee = drops(env.current()->fees().increment); + // Sponsor balance = own reserve + the AccountDelete fee: paying + // the fee charges the sponsor down to exactly its reserve. + adjustAccountXRPBalance(env, sponsor, reserve(env, 0) + requiredFee); + + auto const aliceBalance = env.balance(alice); + auto const bobBalance = env.balance(bob); + env(acctdelete(alice, bob), + Fee(requiredFee), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::account(alice))); + // alice's entire balance went to bob; the fee came from the + // sponsor. + BEAST_EXPECT(env.balance(bob) == bobBalance + aliceBalance); + BEAST_EXPECT(env.balance(sponsor) == reserve(env, 0)); + } + + { + // Co-signed: the sponsor may not dip below its own reserve to pay + // the fee (Transactor::checkFee: SponsorCoSigned spendable = + // balance - reserve). + Env env{*this, testableAmendments()}; + env.fund(XRP(10000), alice, bob, sponsor); + env.close(); + + incLgrSeqForAccDel(env, alice); + + STAmount const requiredFee = drops(env.current()->fees().increment); + adjustAccountXRPBalance(env, sponsor, reserve(env, 0) + requiredFee - drops(1)); + + env(acctdelete(alice, bob), + Fee(requiredFee), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(terINSUF_FEE_B)); + env.close(); + BEAST_EXPECT(env.le(keylet::account(alice))); + } + + { + // Pre-funded: FeeAmount below the AccountDelete fee. + // Transactor::checkFee: prefunded maxSpendable = FeeAmount, which + // is less than the fee paid -> terINSUF_FEE_B on an open ledger. + Env env{*this, testableAmendments()}; + env.fund(XRP(10000), alice, bob, sponsor); + env.close(); + + STAmount const requiredFee = drops(env.current()->fees().increment); + env(sponsor::set_fee(sponsor, 0, requiredFee - drops(1)), sponsor::SponseeAcc(alice)); + env.close(); + + incLgrSeqForAccDel(env, alice); + + env(acctdelete(alice, bob), + Fee(requiredFee), + sponsor::As(sponsor, spfSponsorFee), + Ter(terINSUF_FEE_B)); + env.close(); + BEAST_EXPECT(env.le(keylet::account(alice))); + } + + { + // Pre-funded: MaxFee below the AccountDelete fee. + // Transactor::checkFee: prefunded maxSpendable = + // min(FeeAmount, MaxFee) < fee paid -> terINSUF_FEE_B. + Env env{*this, testableAmendments()}; + env.fund(XRP(10000), alice, bob, sponsor); + env.close(); + + STAmount const requiredFee = drops(env.current()->fees().increment); + env(sponsor::set_fee(sponsor, 0, requiredFee, requiredFee - drops(1)), + sponsor::SponseeAcc(alice)); + env.close(); + + incLgrSeqForAccDel(env, alice); + + env(acctdelete(alice, bob), + Fee(requiredFee), + sponsor::As(sponsor, spfSponsorFee), + Ter(terINSUF_FEE_B)); + env.close(); + BEAST_EXPECT(env.le(keylet::account(alice))); + } + + { + // Pre-funded with a sufficient FeeAmount: the fee check passes, + // but the Sponsorship object itself is an obligation of the + // sponsee, so the delete fails with tecHAS_OBLIGATIONS + // (AccountDelete::preclaim) and the fee is still drawn from the + // pre-funded FeeAmount on context reset (Transactor::reset). + Env env{*this, testableAmendments()}; + env.fund(XRP(10000), alice, bob, sponsor); + env.close(); + + STAmount const requiredFee = drops(env.current()->fees().increment); + env(sponsor::set_fee(sponsor, 0, XRP(1000)), sponsor::SponseeAcc(alice)); + env.close(); + + incLgrSeqForAccDel(env, alice); + + env(acctdelete(alice, bob), + Fee(requiredFee), + sponsor::As(sponsor, spfSponsorFee), + Ter(tecHAS_OBLIGATIONS)); + env.close(); + BEAST_EXPECT(env.le(keylet::account(alice))); + BEAST_EXPECT( + sponsor::sponsorshipFeeBalance(env, sponsor, alice) == XRP(1000) - requiredFee); + } } void @@ -5023,40 +5388,140 @@ class Sponsor_test : public beast::unit_test::Suite // Sponsor paid the fee BEAST_EXPECT(env.balance(bob) == XRP(1000)); } - } - // Verify that the central allow-list in preflight1Sponsor rejects - // spfSponsorReserve for transaction types that v1 does not permit. - void - testReserveSponsorGate() - { - testcase("Reserve sponsor allow-list gate"); - using namespace test::jtx; + // Inner tx co-signed by a sponsor who IS the outer Batch submitter: + // Batch::preflightSigValidated skips the sponsor when collecting + // requiredSigners (sponsor == outerAccount), so no BatchSigners entry + // is needed for the sponsor - it authorizes the batch by signing the + // outer transaction itself. + { + Env env{*this, testableAmendments()}; + env.fund(XRP(1000), alice, bob, sponsor); + env.close(); - 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 jt = env.jtnofill( + check::create(alice, bob, XRP(1)), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + jt.jv.removeMember(sfTxnSignature.jsonName); + jt.jv[sfSponsorSignature.jsonName].removeMember(sfTxnSignature.jsonName); + jt.jv[sfSponsorSignature.jsonName][sfSigningPubKey.jsonName] = ""; - env(sponsor::set(sponsor, 0, 10, XRP(10)), sponsor::SponseeAcc(alice)); - env.close(); + auto const aliceSeq = env.seq(alice); + auto const seq = env.seq(sponsor); + auto const batchFee = batch::calcBatchFee(env, 1, 2); - auto checkBlocked = [&](json::Value const& jv) { - env(jv, - sponsor::As(sponsor, spfSponsorReserve), - Sig(sfSponsorSignature, sponsor), - Ter(temINVALID_FLAG)); - }; + // alice (the inner authorizer) is the only required batch signer. + env(batch::outer(sponsor, seq, batchFee, tfAllOrNothing), + batch::Inner(jt.jv, aliceSeq), + batch::Inner(noop(sponsor), seq + 1), + batch::Sig(alice), + Ter(tesSUCCESS)); + env.close(); - checkBlocked(ticket::create(alice, 1)); - checkBlocked(offer(alice, XRP(100), bob["USD"](100))); + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + auto const checkSle = env.le(keylet::check(alice, aliceSeq)); + BEAST_EXPECT(checkSle && checkSle->getAccountID(sfSponsor) == sponsor.id()); + + // The outer submitter (the sponsor) pays the batch fee. + BEAST_EXPECT(env.balance(alice) == XRP(1000)); + BEAST_EXPECT(env.balance(sponsor) == XRP(1000) - drops(batchFee)); + } + + // Inner sponsor-signature object with a non-empty SigningPubKey (but + // otherwise batch-marker shaped) is rejected by Batch::preflight's + // checkSignatureFields with temBAD_REGKEY. + { + Env env{*this, testableAmendments()}; + env.fund(XRP(1000), alice, bob, sponsor); + env.close(); + + auto jt = env.jtnofill( + check::create(alice, bob, XRP(1)), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + jt.jv.removeMember(sfTxnSignature.jsonName); + jt.jv[sfSponsorSignature.jsonName].removeMember(sfTxnSignature.jsonName); + // Leave the sponsor's SigningPubKey (filled by the Sig helper) + // in place: inner sponsor signatures must be empty markers. + BEAST_EXPECT( + !jt.jv[sfSponsorSignature.jsonName][sfSigningPubKey.jsonName].asString().empty()); + + auto const seq = env.seq(alice); + auto const batchFee = batch::calcBatchFee(env, 1, 2); + env(batch::outer(alice, seq, batchFee, tfAllOrNothing), + batch::Inner(jt.jv, seq + 1), + batch::Inner(noop(alice), seq + 2), + batch::Sig(sponsor), + Ter(temBAD_REGKEY)); + env.close(); + } + } + + // Verify that the central allow-list in preflight1Sponsor rejects + // spfSponsorReserve for transaction types that v1 does not permit. + void + testReserveSponsorGate() + { + testcase("Reserve sponsor allow-list gate"); + 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(); + + env(sponsor::set(sponsor, 0, 10, XRP(10)), sponsor::SponseeAcc(alice)); + env.close(); + + auto checkBlocked = [&](json::Value const& jv) { + env(jv, + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(temINVALID_FLAG)); + }; + + checkBlocked(ticket::create(alice, 1)); + checkBlocked(offer(alice, XRP(100), bob["USD"](100))); checkBlocked(did::setValid(alice)); checkBlocked(token::mint(alice, 0u)); checkBlocked(sponsor::set(alice, 0, 10, XRP(10))); checkBlocked(acctdelete(alice, bob)); checkBlocked(loan::set(alice, uint256(1), Number{1})); + + // AMM transactions are not in the allow-list. + checkBlocked(AMM::createJv(alice.id(), XRP(10), bob["USD"](10), 0)); + + // OracleSet is not in the allow-list. The transaction only needs to be + // well-formed enough to construct the STTx; preflight1Sponsor rejects + // it before the Oracle-specific preflight checks run. + { + json::Value oracleSet; + oracleSet[jss::TransactionType] = jss::OracleSet; + oracleSet[jss::Account] = alice.human(); + oracleSet[jss::OracleDocumentID] = 1; + oracleSet[jss::LastUpdateTime] = 1'000'000u; + json::Value price; + price[jss::BaseAsset] = "XRP"; + price[jss::QuoteAsset] = "USD"; + price[jss::AssetPrice] = 740; + price[jss::Scale] = 1; + json::Value priceData; + priceData[jss::PriceData] = price; + oracleSet[jss::PriceDataSeries] = json::Value(json::ValueType::Array); + oracleSet[jss::PriceDataSeries].append(priceData); + checkBlocked(oracleSet); + } + + // PermissionedDomainSet is not in the allow-list. + checkBlocked(pdomain::setTx(alice.id(), {{.issuer = bob, .credType = "termsofuse"}})); + + // XChain transactions are not in the allow-list. + checkBlocked(bridgeCreate(alice, bridge(alice, xrpIssue(), bob, xrpIssue()), XRP(1))); } void @@ -5429,6 +5894,685 @@ class Sponsor_test : public beast::unit_test::Suite } } + void + testAccountSponsorshipTransferPermissions() + { + // Account-level (no sfObjectID) SponsorshipTransfer permission errors. + testcase("Account-level SponsorshipTransfer permissions"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const sponsor1("sponsor1"); + Account const sponsor2("sponsor2"); + env.fund(XRP(10000), alice, sponsor1, sponsor2); + env.close(); + + // Account-level Reassign without sfSponsorSignature: the + // isAccountReserveSponsorship branch in SponsorshipTransfer::preflight + // requires a co-signature for account-level create AND reassign. + env(sponsor::transfer(alice, tfSponsorshipReassign), + sponsor::As(sponsor2, spfSponsorReserve), + Ter(temMALFORMED)); + + // Account-level Reassign of an account that was never sponsored. + env(sponsor::transfer(alice, tfSponsorshipReassign), + sponsor::As(sponsor2, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor2), + Ter(tecNO_PERMISSION)); + env.close(); + + BEAST_EXPECT(!env.le(alice)->isFieldPresent(sfSponsor)); + BEAST_EXPECT(sponsoringAccountCount(env, sponsor2) == 0); + + // sponsor1 account-sponsors alice. + env(sponsor::transfer(alice, tfSponsorshipCreate), + sponsor::As(sponsor1, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor1)); + env.close(); + + BEAST_EXPECT(env.le(alice)->getAccountID(sfSponsor) == sponsor1.id()); + BEAST_EXPECT(sponsoringAccountCount(env, sponsor1) == 1); + + // Account-level Create on an account that is already account-sponsored. + env(sponsor::transfer(alice, tfSponsorshipCreate), + sponsor::As(sponsor2, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor2), + Ter(tecNO_PERMISSION)); + env.close(); + + // The failed transfer must not change the sponsorship state. + BEAST_EXPECT(env.le(alice)->getAccountID(sfSponsor) == sponsor1.id()); + BEAST_EXPECT(sponsoringAccountCount(env, sponsor1) == 1); + BEAST_EXPECT(sponsoringAccountCount(env, sponsor2) == 0); + } + + void + testCosignedTransferConsumesPrefundedBudget() + { + // A co-signed object-level SponsorshipTransfer(tfSponsorshipCreate) + // still draws down an existing Sponsorship object's RemainingOwnerCount + // (SponsorshipTransfer::doApply calls decrementPrefundedReserveCount + // whenever the Sponsorship SLE exists, signature or not), and + // checkReserve gates the transfer on that budget. + testcase("Co-signed SponsorshipTransfer draws down prefunded budget"); + 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(); + + // An unsponsored check owned by alice. + auto const seq = env.seq(alice); + env(check::create(alice, bob, XRP(1))); + env.close(); + auto const checkId = keylet::check(alice, seq).key; + + // Budgeted sponsorship: RemainingOwnerCount = 2. + env(sponsor::set_reserve(sponsor, 0, 2), sponsor::SponseeAcc(alice)); + env.close(); + + // Co-signed transfer succeeds and decrements the budget 2 -> 1. + env(sponsor::transfer(alice, tfSponsorshipCreate, checkId), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + env.close(); + + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + BEAST_EXPECT(env.le(keylet::check(alice, seq))->getAccountID(sfSponsor) == sponsor.id()); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->getFieldU32(sfRemainingOwnerCount) == 1); + + // Exhaust the budget (a zero update makes the field absent). + env(sponsor::set_reserve(sponsor, 0, 0), sponsor::SponseeAcc(alice)); + env.close(); + BEAST_EXPECT( + !env.le(keylet::sponsorship(sponsor, alice))->isFieldPresent(sfRemainingOwnerCount)); + + // A second unsponsored check. + auto const seq2 = env.seq(alice); + env(check::create(alice, bob, XRP(1))); + env.close(); + auto const checkId2 = keylet::check(alice, seq2).key; + + // With the budget exhausted, the same co-signed transfer fails in + // checkReserve (RemainingOwnerCount 0 < ownerCountDelta 1) with + // tecINSUFFICIENT_RESERVE, even though the sponsor's own balance is + // ample: an existing Sponsorship object's budget always bounds + // reserve sponsorship for that sponsor/sponsee pair. + env(sponsor::transfer(alice, tfSponsorshipCreate, checkId2), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tecINSUFFICIENT_RESERVE)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::check(alice, seq2))->isFieldPresent(sfSponsor)); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + } + + void + testZeroValueUpdateOnAbsentFields() + { + // Updating a Sponsorship with all-zero values when the optional fields + // are already absent must succeed: STObject::makeFieldAbsent + // early-returns on fields that are already not present. + testcase("Zero-value SponsorshipSet update on absent fields"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, sponsor); + env.close(); + + auto const baseFee = env.current()->fees().base; + + // Create a minimal sponsorship: all-zero values leave FeeAmount, + // MaxFee, and RemainingOwnerCount absent. + env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)), sponsor::SponseeAcc(alice)); + env.close(); + + auto sle = env.le(keylet::sponsorship(sponsor, alice)); + BEAST_EXPECT(sle); + BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount)); + BEAST_EXPECT(!sle->isFieldPresent(sfMaxFee)); + BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); + + // Submit the identical all-zero update again: fields are already + // absent, so each makeFieldAbsent call is a no-op and the update + // succeeds. + auto const sponsorBalance = env.balance(sponsor); + env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)), + sponsor::SponseeAcc(alice), + Fee(baseFee), + Ter(tesSUCCESS)); + env.close(); + + sle = env.le(keylet::sponsorship(sponsor, alice)); + BEAST_EXPECT(sle); + BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount)); + BEAST_EXPECT(!sle->isFieldPresent(sfMaxFee)); + BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); + // Only the fee was charged. + BEAST_EXPECT(env.balance(sponsor) == sponsorBalance - drops(baseFee)); + } + + void + testEmptySponsorSignatureObject() + { + // A completely empty SponsorSignature object (empty SigningPubKey, no + // TxnSignature, no Signers) on a normal (non-batch) transaction. The + // empty SigningPubKey routes STTx::checkMultiSign, which throws on the + // missing sfSigners array; the signature check therefore fails at the + // RPC/signing layer before the engine ever sees the transaction, which + // jtx reports as telENV_RPC_FAILED. + testcase("Empty SponsorSignature object on a normal transaction"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const sponsor("sponsor"); + 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), + 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 + testOuterMultisignedSponsoredTx() + { + // A fee-sponsored transaction whose OUTER signature is a multisig. + // STTx::checkMultiSign validates the outer Signers against + // getInitiator() (the account/delegate), not getFeePayerID(), so the + // sponsor may pay the fee for a transaction it did not initiate, and + // may even be a member of the initiator's signer list. + testcase("Outer-multisigned fee-sponsored transaction"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, bob, carol, sponsor); + env.close(); + + auto const baseFee = env.current()->fees().base; + + { + env(signers(alice, 2, {{bob, 1}, {carol, 1}})); + env.close(); + + // calculateBaseFee: base + (outer signers + sponsor signers) * + // base. Two outer signers and a single-signed sponsor -> 3x base. + env(noop(alice), + Msig({bob, carol}), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Fee(baseFee + 2 * baseFee - 1), + Ter(telINSUF_FEE_P)); + + auto const aliceBalance = env.balance(alice); + auto const sponsorBalance = env.balance(sponsor); + env(noop(alice), + Msig({bob, carol}), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Fee(baseFee + 2 * baseFee), + Ter(tesSUCCESS)); + env.close(); + + // The sponsor pays the multisig-scaled fee; alice pays nothing. + BEAST_EXPECT(env.balance(alice) == aliceBalance); + BEAST_EXPECT(env.balance(sponsor) == sponsorBalance - drops(3 * baseFee)); + } + + { + // The sponsor is itself a member of alice's signer list and signs + // both as an outer multisigner and as the fee sponsor. This is + // legal: the outer Signers are checked against the initiator + // (alice), which the sponsor is not. + env(signers(alice, 2, {{bob, 1}, {sponsor, 1}})); + env.close(); + + auto const aliceBalance = env.balance(alice); + auto const sponsorBalance = env.balance(sponsor); + env(noop(alice), + Msig({bob, sponsor}), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Fee(baseFee + 2 * baseFee), + Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(env.balance(alice) == aliceBalance); + BEAST_EXPECT(env.balance(sponsor) == sponsorBalance - drops(3 * baseFee)); + } + } + + void + testSponsoredAccountBaseReserveExclusion() + { + // An account-sponsored account contributes 0 to its own account + // reserve (accountCountImpl: isSponsored -> 0), so xrpLiquid excludes + // the base reserve entirely. A sponsored account whose balance is + // below the base reserve can still fund an xrpLiquid-gated action + // (OfferCreate selling XRP) that an identical unsponsored account + // cannot. + testcase("Sponsored account base-reserve exclusion in xrpLiquid"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const bob("bob"); + Account const gw("gw"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, bob, gw, sponsor); + env.close(); + + auto const usd = gw["USD"]; + auto const fees = env.current()->fees(); + + // Target balance: covers one owner-reserve increment (the offer) but + // is below the base account reserve. + auto const target = drops(fees.accountReserve(1, 0)) + XRP(10); + BEAST_EXPECT(target < drops(fees.accountReserve(0, 1))); + + // Account-sponsor alice; bob stays unsponsored as the control. + env(sponsor::transfer(alice, tfSponsorshipCreate), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + env.close(); + BEAST_EXPECT(env.le(alice)->getAccountID(sfSponsor) == sponsor.id()); + + // Drain both accounts below the base reserve by burning fees: an + // ordinary fee payer may dip below its reserve (a Payment could not + // take the unsponsored control below the base reserve). + env(noop(alice), Fee(env.balance(alice) - target)); + env(noop(bob), Fee(env.balance(bob) - target)); + env.close(); + BEAST_EXPECT(env.balance(alice) == target); + BEAST_EXPECT(env.balance(bob) == target); + + // Control: for the unsponsored account, xrpLiquid = balance - base + // reserve = 0, so OfferCreate's funding check fails. + env(offer(bob, usd(5), XRP(5)), Ter(tecUNFUNDED_OFFER)); + env.close(); + BEAST_EXPECT(ownerCount(env, bob) == 0); + + // 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); + env(offer(alice, usd(5), XRP(5)), Ter(tesSUCCESS)); + env.close(); + + BEAST_EXPECT(env.le(keylet::offer(alice, offerSeq))); + BEAST_EXPECT(ownerCount(env, alice) == 1); + // alice is still below the (unsponsored) base reserve. + BEAST_EXPECT(env.balance(alice) < drops(fees.accountReserve(0, 1))); + } + + void + testThirdPartyEscrowCancelRefundsSponsor() + { + // EscrowCancel of a sponsored escrow submitted by a THIRD PARTY + // (neither the owner nor the sponsor) after CancelAfter: the deletion + // must refund the sponsor's SponsoringOwnerCount and the owner's + // SponsoredOwnerCount. + testcase("Third-party EscrowCancel refunds the sponsor"); + using namespace test::jtx; + using namespace std::chrono_literals; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const bob("bob"); + Account const carol("carol"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, bob, carol, sponsor); + env.close(); + + auto const cancelAfter = env.now() + 100s; + auto const seq = env.seq(alice); + env(escrow::create(alice, bob, XRP(100)), + escrow::kCondition(escrow::kCb1), + escrow::kCancelTime(cancelAfter), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + BEAST_EXPECT(env.le(keylet::escrow(alice, seq))->getAccountID(sfSponsor) == sponsor.id()); + + // Advance past the cancel time. + for (; env.now() <= cancelAfter; env.close()) + { + } + + // carol (a third party) cancels the escrow. + auto const aliceBalance = env.balance(alice); + env(escrow::cancel(carol, alice, seq)); + env.close(); + + BEAST_EXPECT(!env.le(keylet::escrow(alice, seq))); + BEAST_EXPECT(env.balance(alice) == aliceBalance + XRP(100)); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); + } + + void + testPaymentEngineTrustLineDeletion() + { + // A reserve-sponsored trust line deleted inside the payment engine + // (updateTrustLine/trustDelete via redeemIOU), not by TrustSet, must + // still refund the sponsor's SponsoringOwnerCount. + testcase("Payment-engine deletion of a sponsored trust line"); + using namespace test::jtx; + + Account const alice("alice"); + Account const gw("gw"); + Account const sponsor("sponsor"); + + { + Env env{*this, testableAmendments()}; + env.fund(XRP(10000), alice, gw, sponsor); + env.close(); + + auto const usd = gw["USD"]; + auto const lineId = keylet::trustLine(alice, gw, usd.currency); + + env(trust(alice, usd(100))); + env.close(); + BEAST_EXPECT(env.le(lineId)); + + // Transfer the line's reserve to the sponsor. + env(sponsor::transfer(alice, tfSponsorshipCreate, lineId.key), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + env.close(); + + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + + env(pay(gw, alice, usd(50))); + env.close(); + + // Setting the limit back to 0 keeps the line alive because the + // balance is non-zero. + env(trust(alice, usd(0))); + env.close(); + BEAST_EXPECT(env.le(lineId)); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + + // Paying the full balance back to the issuer deletes the line + // inside the payment engine. + env(pay(alice, gw, usd(50))); + env.close(); + + BEAST_EXPECT(!env.le(lineId)); + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); + } + + { + // AMM variant: a sponsored LP-token trust line is deleted by + // AMMWithdraw (withdraw-all) when the LP tokens are fully + // redeemed. Note: the AMM's own asset lines (owned by the AMM + // pseudo-account) cannot be sponsored - pseudo-accounts cannot + // submit SponsorshipTransfer and a third party gets + // tecNO_PERMISSION - so only the LP-side line is testable here. + Env env{*this, testableAmendments()}; + env.fund(XRP(10000), alice, gw, sponsor); + env.close(); + + auto const usd = gw["USD"]; + env(trust(alice, usd(1000))); + env.close(); + env(pay(gw, alice, usd(500))); + env.close(); + + AMM amm(env, alice, XRP(100), usd(100)); + + auto const lpLine = + keylet::trustLine(alice.id(), amm.ammAccount(), amm.lptIssue().currency); + BEAST_EXPECT(env.le(lpLine)); + + env(sponsor::transfer(alice, tfSponsorshipCreate, lpLine.key), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + env.close(); + + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + + // Withdraw everything: the LP-token line is deleted as the LP + // balance is redeemed to zero. + amm.withdrawAll(alice); + env.close(); + + BEAST_EXPECT(!env.le(lpLine)); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); + } + } + + void + testReserveSponsorAllowListNoOp() + { + // Allow-listed transaction types that create no owner-counted object: + // spfSponsorReserve with a valid co-sign is a harmless no-op. The + // transactions succeed and neither party gains any sponsorship count + // fields. + testcase("Reserve sponsor no-op on allow-listed non-creating txs"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const bob("bob"); + Account const gw("gw"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, bob, gw, sponsor); + env.close(); + + auto const expectNoSponsorCounts = [&](Account const& acc) { + auto const sle = env.le(keylet::account(acc)); + if (BEAST_EXPECT(sle)) + { + BEAST_EXPECT(!sle->isFieldPresent(sfSponsoredOwnerCount)); + BEAST_EXPECT(!sle->isFieldPresent(sfSponsoringOwnerCount)); + BEAST_EXPECT(!sle->isFieldPresent(sfSponsoringAccountCount)); + } + }; + + // AccountSet + env(noop(alice), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + expectNoSponsorCounts(alice); + expectNoSponsorCounts(sponsor); + + // SetRegularKey + env(regkey(alice, bob), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + expectNoSponsorCounts(alice); + expectNoSponsorCounts(sponsor); + + // Clawback (partial, so the trust line survives) + env(fset(gw, asfAllowTrustLineClawback)); + env.close(); + env(trust(alice, gw["USD"](100))); + env.close(); + env(pay(gw, alice, gw["USD"](50))); + env.close(); + env(claw(gw, alice["USD"](10)), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + expectNoSponsorCounts(gw); + expectNoSponsorCounts(sponsor); + + // 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()); + jvSet[sfFlags.jsonName] = tfMPTLock; + env(jvSet, + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + expectNoSponsorCounts(alice); + expectNoSponsorCounts(sponsor); + } + + void + testPseudoAccountAsSponsor() + { + // A pseudo-account (an AMM account) named as sfSponsor on a co-signed + // fee-sponsored transaction. The signature itself is cryptographically + // valid (made with an arbitrary real key), so it passes the local + // crypto check; Transactor::checkSign then rejects the sponsor because + // pseudo-accounts can never sign -> tefBAD_AUTH. + testcase("Pseudo account named as co-signing sponsor"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const bob("bob"); + Account const gw("gw"); + env.fund(XRP(10000), alice, bob, gw); + env.close(); + + auto const usd = gw["USD"]; + env(trust(alice, usd(1000))); + env.close(); + env(pay(gw, alice, usd(500))); + env.close(); + + AMM amm(env, alice, XRP(100), usd(100)); + + // rogue is never funded; it only supplies a real signing keypair. + Account const rogue("rogue"); + + auto tx = noop(bob); + tx[sfSponsor.jsonName] = to_string(amm.ammAccount()); + tx[sfSponsorFlags.jsonName] = static_cast(spfSponsorFee); + + auto const bobBalance = env.balance(bob); + env(tx, Fee(XRP(1)), Sig(sfSponsorSignature, rogue), Ter(tefBAD_AUTH)); + env.close(); + + // tef results claim no fee; nothing changed. + BEAST_EXPECT(env.balance(bob) == bobBalance); + } + + void + testCosignedClosedLedgerFeeCap() + { + // Mirror of the pre-funded OpenView-overlay test in testSponsorFee, + // for the CO-SIGNED path: on a closed-ledger view, checkFee returns + // tecINSUFF_FEE when the sponsor's spendable balance (balance minus + // its own reserve) is positive but below the fee, and reset() caps + // the charged fee so the sponsor never dips below its reserve. + testcase("Co-signed sponsor fee on closed ledger caps at reserve"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, sponsor); + env.close(); + + // Spendable above reserve: exactly 10 drops. + adjustAccountXRPBalance(env, sponsor, reserve(env, 0) + drops(10)); + + // Apply directly against the closed ledger view (open_ = false). + OpenView overlay(&*env.closed()); + + auto jt = env.jt( + noop(alice), + Fee(drops(1000)), + Seq(env.seq(alice)), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor)); + + auto const result = xrpl::apply(env.app(), overlay, *jt.stx, TapNone, env.journal); + BEAST_EXPECT(result.ter == tecINSUFF_FEE); + BEAST_EXPECT(result.applied); + + // Only the 10 spendable drops were charged: the co-signed sponsor is + // never charged into its own account reserve. + auto const sle = overlay.read(keylet::account(sponsor.id())); + BEAST_EXPECT(sle); + BEAST_EXPECT(sle && sle->getFieldAmount(sfBalance) == reserve(env, 0)); + } + protected: void testSponsor() @@ -5446,6 +6590,7 @@ class Sponsor_test : public beast::unit_test::Suite testPreFundAndCosign(); testSponsoredFreeTierReserve(); + testSponsoredTicketUse(); testTransferSponsor(); testLegacySignerListReserve(); @@ -5470,6 +6615,19 @@ class Sponsor_test : public beast::unit_test::Suite testFeeSponsoredVaultInvariant(); testSponsoredObjectDeletionRefund(); + + testAccountSponsorshipTransferPermissions(); + testCosignedTransferConsumesPrefundedBudget(); + testZeroValueUpdateOnAbsentFields(); + testEmptySponsorSignatureObject(); + testFeeOnlySponsorshipObjectRouting(); + testOuterMultisignedSponsoredTx(); + testSponsoredAccountBaseReserveExclusion(); + testThirdPartyEscrowCancelRefundsSponsor(); + testPaymentEngineTrustLineDeletion(); + testReserveSponsorAllowListNoOp(); + testPseudoAccountAsSponsor(); + testCosignedClosedLedgerFeeCap(); } void diff --git a/src/test/app/TxQ_test.cpp b/src/test/app/TxQ_test.cpp index 3175e742d97..53cc051a841 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 @@ -40,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -2374,6 +2376,90 @@ class TxQPosNegFlows_test : public beast::unit_test::Suite checkMetrics(*this, env, 0, 6, 5, 3); } + void + testSponsorReserveTxCanQueue() + { + using namespace jtx; + testcase("allow reserve-only sponsored transaction to be queued"); + + 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(); + + fillQueue(env, filler); + checkMetrics(*this, env, 0, 6, 4, 3); + + // Only fee-sponsored transactions are barred from the queue. A + // reserve-only sponsored transaction may be held like any other. + auto const checkSeq = env.seq(sponsee); + env(check::create(sponsee, filler, XRP(10)), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor), + Ter(terQUEUED)); + checkMetrics(*this, env, 1, 6, 4, 3); + + // Once the ledger closes, the queued transaction applies with the + // sponsorship intact. + env.close(); + checkMetrics(*this, env, 0, 8, 1, 4); + + auto const checkSle = env.le(keylet::check(sponsee, checkSeq)); + if (BEAST_EXPECT(checkSle)) + BEAST_EXPECT(checkSle->at(~sfSponsor) == sponsor.id()); + auto const sponseeSle = env.le(keylet::account(sponsee)); + if (BEAST_EXPECT(sponseeSle)) + BEAST_EXPECT(sponseeSle->at(~sfSponsoredOwnerCount) == 1); + auto const sponsorSle = env.le(keylet::account(sponsor)); + if (BEAST_EXPECT(sponsorSle)) + BEAST_EXPECT(sponsorSle->at(~sfSponsoringOwnerCount) == 1); + } + + void + testSponsorPrefundedTxCannotQueue() + { + using namespace jtx; + testcase("disallow pre-funded fee-sponsored transaction from being queued"); + + 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(); + + // Pre-funded fee sponsorship: the sponsor does not co-sign. + env(sponsor::set_fee(sponsor, 0, XRP(100)), sponsor::SponseeAcc(sponsee)); + env.close(); + + fillQueue(env, filler); + checkMetrics(*this, env, 0, 6, 4, 3); + + // The TxQ::canBeHeld guard is on fee sponsorship (sfSponsor with + // spfSponsorFee), independent of whether a sponsor signature is + // present, so a pre-funded fee-sponsored transaction cannot be + // queued either. + env(noop(sponsee), sponsor::As(sponsor, spfSponsorFee), Ter(telCAN_NOT_QUEUE)); + checkMetrics(*this, env, 0, 6, 4, 3); + + // It may still apply directly if it pays the open ledger fee. + env(noop(sponsee), + sponsor::As(sponsor, spfSponsorFee), + Fee(openLedgerCost(env)), + Ter(tesSUCCESS)); + checkMetrics(*this, env, 0, 6, 5, 3); + } + void testDelegateTxCannotQueue() { @@ -4738,6 +4824,8 @@ class TxQPosNegFlows_test : public beast::unit_test::Suite testBlockersTicket(); testInFlightBalance(); testSponsorTxCannotQueue(); + testSponsorReserveTxCanQueue(); + testSponsorPrefundedTxCannotQueue(); testDelegateTxCannotQueue(); testConsequences(); } diff --git a/src/test/ledger/OwnerCounts_test.cpp b/src/test/ledger/OwnerCounts_test.cpp new file mode 100644 index 00000000000..86e65c1aee1 --- /dev/null +++ b/src/test/ledger/OwnerCounts_test.cpp @@ -0,0 +1,188 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl::test { + +class OwnerCounts_test : public beast::unit_test::Suite +{ + static OwnerCounts + makeCounts(std::uint32_t owner, std::uint32_t sponsored, std::uint32_t sponsoring) + { + OwnerCounts counts; + counts.owner = owner; + counts.sponsored = sponsored; + counts.sponsoring = sponsoring; + return counts; + } + + void + testCount() + { + // count() == owner - sponsored + sponsoring, saturating at the + // uint32 maximum. + testcase("count"); + + constexpr auto maxU32 = std::numeric_limits::max(); + + { + BEAST_EXPECT(OwnerCounts().count() == 0); + // 5 - 2 + 3 == 6 + BEAST_EXPECT(makeCounts(5, 2, 3).count() == 6); + // owner only + BEAST_EXPECT(makeCounts(7, 0, 0).count() == 7); + // fully sponsored: 4 - 4 + 0 == 0 + BEAST_EXPECT(makeCounts(4, 4, 0).count() == 0); + } + + // count() saturation: totals above uint32 max clamp to uint32 max + { + // maxU32 - 0 + 1 overflows uint32 and clamps + BEAST_EXPECT(makeCounts(maxU32, 0, 1).count() == maxU32); + // clamp applies no matter how far past the max + BEAST_EXPECT(makeCounts(maxU32, 0, maxU32).count() == maxU32); + } + } + + void + testOrdering() + { + // The operator<=> tie-break chain decides what DeferredCredits + // stores via std::max when PaymentSandbox layers merge. + testcase("ordering"); + + constexpr auto maxU32 = std::numeric_limits::max(); + + // count() dominates the comparison + { + auto const a = makeCounts(1, 0, 0); // count() == 1 + auto const b = makeCounts(3, 0, 0); // count() == 3 + BEAST_EXPECT(a < b); + BEAST_EXPECT(b > a); + BEAST_EXPECT(std::max(a, b) == b); + } + + // Tie-break 1: equal count(), higher owner wins + // a: 2 - 1 + 0 == 1, b: 1 - 0 + 0 == 1 + { + auto const a = makeCounts(2, 1, 0); + auto const b = makeCounts(1, 0, 0); + BEAST_EXPECT(a.count() == b.count()); + BEAST_EXPECT(a > b); + // Both argument orders agree on which one std::max keeps + BEAST_EXPECT(std::max(a, b) == a); + BEAST_EXPECT(std::max(b, a) == a); + } + + // Tie-break 2: equal count() and owner, higher sponsored wins + // a: 5 - 2 + 1 == 4, b: 5 - 3 + 2 == 4 + { + auto const a = makeCounts(5, 2, 1); + auto const b = makeCounts(5, 3, 2); + BEAST_EXPECT(a.count() == b.count()); + BEAST_EXPECT(a.owner == b.owner); + BEAST_EXPECT(a < b); + BEAST_EXPECT(std::max(a, b) == b); + } + + // Tie-break 3: equal count(), owner, and sponsored, higher + // sponsoring wins. With exact arithmetic, equal count(), owner, and + // sponsored force equal sponsoring, so this arm is only reachable + // when count() saturates: both clamp to uint32 max. + { + auto const a = makeCounts(maxU32, 0, 1); + auto const b = makeCounts(maxU32, 0, 2); + BEAST_EXPECT(a.count() == b.count()); + BEAST_EXPECT(a < b); + BEAST_EXPECT(std::max(a, b) == b); + } + } + + void + testEquality() + { + 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 + BEAST_EXPECT(a != makeCounts(3, 1, 3)); // differing sponsoring + // Equal count() (3 - 1 + 2 == 4 - 0 + 0) is not enough for + // equality; the fields themselves must match + BEAST_EXPECT(a.count() == makeCounts(4, 0, 0).count()); + BEAST_EXPECT(a != makeCounts(4, 0, 0)); + } + + void + testSleConstruction() + { + // Construction from an AccountRoot SLE reads sfOwnerCount and treats + // the soeDEFAULT sponsorship fields as 0 when absent. + testcase("sleConstruction"); + + auto const alice = jtx::Account("alice"); + auto const makeAccountRoot = [&alice]() { + return std::make_shared(keylet::account(alice.id())); + }; + + // All sponsorship fields absent: only sfOwnerCount contributes. + { + auto const sle = makeAccountRoot(); + sle->setFieldU32(sfOwnerCount, 7); + + OwnerCounts const counts(sle); + BEAST_EXPECT(counts.owner == 7); + BEAST_EXPECT(counts.sponsored == 0); + BEAST_EXPECT(counts.sponsoring == 0); + BEAST_EXPECT(counts.count() == 7); + } + + // All three fields present. + { + auto const sle = makeAccountRoot(); + sle->setFieldU32(sfOwnerCount, 5); + sle->setFieldU32(sfSponsoredOwnerCount, 2); + sle->setFieldU32(sfSponsoringOwnerCount, 3); + + OwnerCounts const counts(sle); + BEAST_EXPECT(counts.owner == 5); + BEAST_EXPECT(counts.sponsored == 2); + BEAST_EXPECT(counts.sponsoring == 3); + BEAST_EXPECT(counts.count() == 6); + BEAST_EXPECT(counts == makeCounts(5, 2, 3)); + } + + // A default AccountRoot (required sfOwnerCount defaults to 0) is the + // zero value. + { + auto const sle = makeAccountRoot(); + BEAST_EXPECT(OwnerCounts(sle) == OwnerCounts()); + } + } + +public: + void + run() override + { + testCount(); + testOrdering(); + testEquality(); + testSleConstruction(); + } +}; + +BEAST_DEFINE_TESTSUITE(OwnerCounts, ledger, xrpl); + +} // namespace xrpl::test diff --git a/src/test/ledger/PaymentSandbox_test.cpp b/src/test/ledger/PaymentSandbox_test.cpp index 75f7410f948..731acecdbe7 100644 --- a/src/test/ledger/PaymentSandbox_test.cpp +++ b/src/test/ledger/PaymentSandbox_test.cpp @@ -40,6 +40,16 @@ namespace xrpl::test { class PaymentSandbox_test : public beast::unit_test::Suite { + static OwnerCounts + makeCounts(std::uint32_t owner, std::uint32_t sponsored, std::uint32_t sponsoring) + { + OwnerCounts counts; + counts.owner = owner; + counts.sponsored = sponsored; + counts.sponsoring = sponsoring; + return counts; + } + /* Create paths so one path funds another path. @@ -483,6 +493,96 @@ class PaymentSandbox_test : public beast::unit_test::Suite } } + void + testOwnerCountApply(FeatureBitset features) + { + // Test that applying a nested PaymentSandbox to its parent merges + // the deferred owner counts, keeping the max for each account. + testcase("ownerCountApply"); + + using namespace jtx; + Env env(*this, features); + Account const alice("alice"); + Account const bob("bob"); + + env.fund(XRP(10000), alice, bob); + env.close(); + + // Merge into a parent with no entry for the account: the child's + // value is inserted as-is. + { + ApplyViewImpl av(&*env.current(), TapNone); + PaymentSandbox sb(&av); + + auto const counts = makeCounts(4, 1, 2); + { + PaymentSandbox sb2(&sb); + sb2.adjustOwnerCountHook(alice, OwnerCounts(), counts); + + // The parent has no entry for alice yet + BEAST_EXPECT(sb.ownerCountHook(alice, OwnerCounts()) == OwnerCounts()); + + sb2.apply(sb); + } + BEAST_EXPECT(sb.ownerCountHook(alice, OwnerCounts()) == counts); + } + + // Merge into a parent that already has a lower entry: the child's + // higher value wins. + { + ApplyViewImpl av(&*env.current(), TapNone); + PaymentSandbox sb(&av); + + auto const lower = makeCounts(2, 0, 0); // count() == 2 + auto const higher = makeCounts(5, 0, 0); // count() == 5 + + sb.adjustOwnerCountHook(bob, OwnerCounts(), lower); + { + PaymentSandbox sb2(&sb); + sb2.adjustOwnerCountHook(bob, OwnerCounts(), higher); + sb2.apply(sb); + } + BEAST_EXPECT(sb.ownerCountHook(bob, OwnerCounts()) == higher); + } + + // The reverse: the parent already has the higher entry; a lower + // value merged from the child does not lower it. + { + ApplyViewImpl av(&*env.current(), TapNone); + PaymentSandbox sb(&av); + + auto const lower = makeCounts(2, 0, 0); + auto const higher = makeCounts(5, 0, 0); + + sb.adjustOwnerCountHook(bob, OwnerCounts(), higher); + { + PaymentSandbox sb2(&sb); + sb2.adjustOwnerCountHook(bob, OwnerCounts(), lower); + sb2.apply(sb); + } + BEAST_EXPECT(sb.ownerCountHook(bob, OwnerCounts()) == higher); + } + + // Merge with equal count() but different composition: the + // operator<=> tie-break (higher owner) decides which one survives. + // a: 2 - 1 + 0 == 1, b: 1 - 0 + 0 == 1 + { + ApplyViewImpl av(&*env.current(), TapNone); + PaymentSandbox sb(&av); + + auto const a = makeCounts(2, 1, 0); + auto const b = makeCounts(1, 0, 0); + + sb.adjustOwnerCountHook(alice, OwnerCounts(), b); + { + PaymentSandbox sb2(&sb); + sb2.adjustOwnerCountHook(alice, OwnerCounts(), a); + sb2.apply(sb); + } + BEAST_EXPECT(sb.ownerCountHook(alice, OwnerCounts()) == a); + } + } + void testOwnerCountWithTransaction(FeatureBitset features) { @@ -619,6 +719,7 @@ class PaymentSandbox_test : public beast::unit_test::Suite testReserve(features); testBalanceHook(features); testOwnerCountHook(features); + testOwnerCountApply(features); }; using namespace jtx; auto const sa = testableAmendments(); diff --git a/src/test/rpc/AccountObjects_test.cpp b/src/test/rpc/AccountObjects_test.cpp index c656c97a4ce..28621475b81 100644 --- a/src/test/rpc/AccountObjects_test.cpp +++ b/src/test/rpc/AccountObjects_test.cpp @@ -1616,6 +1616,49 @@ class AccountObjects_test : public beast::unit_test::Suite BEAST_EXPECT(objs.size() == 0); } } + + // NFTokenPages can never be sponsored (no NFT transaction is + // reserve-sponsorable), so sponsored=true must always exclude them + // while sponsored=false includes them. + { + Env env(*this, testableAmendments()); + Account const minter("minter"); + + env.fund(XRP(10000), minter); + env.close(); + + env(token::mint(minter, 0u)); + env.close(); + + // A single mint produces one page, stored at the account's + // maximum possible page keylet. + if (!BEAST_EXPECT(env.le(keylet::nftokenPageMax(minter)))) + return; + + { + auto const resp = acctObjsSponsored(env, minter.id(), false, jss::nft_page); + auto const& objs = resp[jss::result][jss::account_objects]; + if (BEAST_EXPECT(objs.size() == 1)) + BEAST_EXPECT(objs[0u][sfLedgerEntryType.jsonName] == jss::NFTokenPage); + } + { + auto const resp = acctObjsSponsored(env, minter.id(), true, jss::nft_page); + BEAST_EXPECT(resp[jss::result][jss::account_objects].size() == 0); + } + + // The same holds when no type filter is given: the page is the + // minter's only object. + { + auto const resp = acctObjsSponsored(env, minter.id(), false); + auto const& objs = resp[jss::result][jss::account_objects]; + if (BEAST_EXPECT(objs.size() == 1)) + BEAST_EXPECT(objs[0u][sfLedgerEntryType.jsonName] == jss::NFTokenPage); + } + { + auto const resp = acctObjsSponsored(env, minter.id(), true); + BEAST_EXPECT(resp[jss::result][jss::account_objects].size() == 0); + } + } } void diff --git a/src/test/rpc/JSONRPC_test.cpp b/src/test/rpc/JSONRPC_test.cpp index e18974e7e72..4e1fe3b1825 100644 --- a/src/test/rpc/JSONRPC_test.cpp +++ b/src/test/rpc/JSONRPC_test.cpp @@ -10,12 +10,14 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -23,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -2718,6 +2721,143 @@ class JSONRPC_test : public beast::unit_test::Suite } } + void + testSponsoredMultiSign() + { + testcase("sign_for/submit_multisigned with a sponsored transaction"); + + using namespace test::jtx; + Env env(*this, envconfig([](std::unique_ptr cfg) { + cfg->loadFromString(std::string("[") + Sections::kSigningSupport + "]\ntrue"); + return cfg; + })); + + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; + Account const sponsor{"sponsor"}; + env.fund(XRP(10000), alice, bob, carol, sponsor); + env.close(); + + // alice's signer list includes the sponsor: the sponsor is a valid + // multisigner for alice's transactions even when it is also the fee + // payer. + env(signers(alice, 2, {{bob, 1}, {carol, 1}, {sponsor, 1}})); + env.close(); + + auto const baseFee = env.current()->fees().base; + + // Build a fee-sponsored transaction co-signed by the sponsor, the + // way an offline sponsor co-signature is produced. The signing + // fields (Sequence, Fee, ...) must be fixed before the sponsor + // 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; + tx[jss::Fee] = (3 * baseFee).jsonClipped(); + tx[jss::Sequence] = env.seq(alice); + tx[jss::SigningPubKey] = ""; + tx[sfSponsor.jsonName] = sponsor.human(); + tx[sfSponsorFlags.jsonName] = spfSponsorFee; + // Fills SponsorSignature.SigningPubKey and + // SponsorSignature.TxnSignature. + tx[sfSponsorSignature.jsonName] = json::Value(json::ValueType::Object); + test::jtx::sign(tx, sponsor, tx[sfSponsorSignature.jsonName]); + return tx; + }; + + { + // Success: bob and the sponsor multisign for alice via sign_for, + // then the transaction is applied via submit_multisigned. The + // Signers array must be validated against alice (the initiator), + // not against the fee payer (the sponsor). + auto const aliceSeq = env.seq(alice); + auto const aliceBalance = env.balance(alice); + auto const sponsorBalance = env.balance(sponsor); + + json::Value jvOne; + jvOne[jss::tx_json] = setupTx(); + jvOne[jss::account] = bob.human(); + jvOne[jss::secret] = bob.name(); + auto jrr = env.rpc("json", "sign_for", to_string(jvOne))[jss::result]; + BEAST_EXPECTS(jrr[jss::status] == "success", to_string(jrr)); + + // The sponsor itself signs for alice. If signer validation were + // keyed to the fee payer, this would be rejected as the sponsor + // "signing for itself". + json::Value jvTwo; + jvTwo[jss::tx_json] = jrr[jss::tx_json]; + jvTwo[jss::account] = sponsor.human(); + jvTwo[jss::secret] = sponsor.name(); + jrr = env.rpc("json", "sign_for", to_string(jvTwo))[jss::result]; + BEAST_EXPECTS(jrr[jss::status] == "success", to_string(jrr)); + + json::Value jvSubmit; + jvSubmit[jss::tx_json] = jrr[jss::tx_json]; + jrr = env.rpc("json", "submit_multisigned", to_string(jvSubmit))[jss::result]; + BEAST_EXPECTS(jrr[jss::status] == "success", to_string(jrr)); + BEAST_EXPECTS(jrr[jss::engine_result] == "tesSUCCESS", to_string(jrr)); + env.close(); + + BEAST_EXPECT(env.seq(alice) == aliceSeq + 1); + // The sponsor paid the fee; alice paid nothing. + BEAST_EXPECT(env.balance(sponsor) == sponsorBalance - drops(3 * baseFee)); + BEAST_EXPECT(env.balance(alice) == aliceBalance); + } + + { + // sign_for: alice may not multisign for her own sponsored + // transaction, even though the fee payer is the sponsor. + json::Value jv; + jv[jss::tx_json] = setupTx(); + jv[jss::account] = alice.human(); + jv[jss::secret] = alice.name(); + auto const jrr = env.rpc("json", "sign_for", to_string(jv))[jss::result]; + BEAST_EXPECTS(jrr[jss::status] == "error", to_string(jrr)); + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + BEAST_EXPECT( + jrr[jss::error_message].asString() == + "A Signer may not be the transaction's Account (" + alice.human() + ")."); + } + + { + // submit_multisigned: a Signers array containing alice is + // rejected for alice's sponsored transaction, even though the + // fee payer is the sponsor. + auto const aliceSeq = env.seq(alice); + + json::Value jvOne; + jvOne[jss::tx_json] = setupTx(); + jvOne[jss::account] = bob.human(); + jvOne[jss::secret] = bob.name(); + auto jrr = env.rpc("json", "sign_for", to_string(jvOne))[jss::result]; + BEAST_EXPECTS(jrr[jss::status] == "success", to_string(jrr)); + + json::Value jvSubmit; + jvSubmit[jss::tx_json] = jrr[jss::tx_json]; + { + // Manually inject alice into her own Signers array. + json::Value signer; + signer[jss::Account] = alice.human(); + signer[jss::SigningPubKey] = strHex(alice.pk().slice()); + signer[jss::TxnSignature] = "1200ABCD"; + json::Value signerOuter; + signerOuter[sfSigner.jsonName] = signer; + jvSubmit[jss::tx_json][sfSigners.jsonName].append(signerOuter); + } + jrr = env.rpc("json", "submit_multisigned", to_string(jvSubmit))[jss::result]; + BEAST_EXPECTS(jrr[jss::status] == "error", to_string(jrr)); + BEAST_EXPECT(jrr[jss::error] == "invalidParams"); + BEAST_EXPECT( + jrr[jss::error_message].asString() == + "A Signer may not be the transaction's Account (" + alice.human() + ")."); + env.close(); + BEAST_EXPECT(env.seq(alice) == aliceSeq); + } + } + // A function that can be called as though it would process a transaction. static void fakeProcessTransaction(std::shared_ptr&, bool, bool, NetworkOPs::FailHard) @@ -2842,6 +2982,7 @@ class JSONRPC_test : public beast::unit_test::Suite testAutoFillFees(); testAutoFillEscalatedFees(); testAutoFillNetworkID(); + testSponsoredMultiSign(); testTransactionRPC(); } }; diff --git a/src/test/rpc/Simulate_test.cpp b/src/test/rpc/Simulate_test.cpp index 16df733a666..4618f87deb0 100644 --- a/src/test/rpc/Simulate_test.cpp +++ b/src/test/rpc/Simulate_test.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -395,6 +396,48 @@ class Simulate_test : public beast::unit_test::Suite "Invalid field 'tx.SponsorSignature.Signers'.", resp.toStyledString()); } + { + // 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 sponsorSignature = json::ValueType::Object; + sponsorSignature[jss::TxnSignature] = "1200ABCD"; + txJson[sfSponsorSignature] = sponsorSignature; + params[jss::tx_json] = txJson; + + auto const resp = env.rpc("json", "simulate", to_string(params)); + BEAST_EXPECTS( + resp[jss::result][jss::error_message] == "Transaction should not be signed.", + resp.toStyledString()); + } + { + // Signed SponsorSignature (non-empty TxnSignature in the nested + // Signers array) + json::Value params; + json::Value txJson = json::ValueType::Object; + txJson[jss::TransactionType] = jss::AccountSet; + txJson[jss::Account] = env.master.human(); + json::Value sponsorSignature = json::ValueType::Object; + sponsorSignature[sfSigners] = json::ValueType::Array; + { + json::Value signer; + signer[jss::Account] = alice.human(); + signer[jss::SigningPubKey] = ""; + signer[jss::TxnSignature] = "1200ABCD"; + json::Value signerOuter; + signerOuter[sfSigner] = signer; + sponsorSignature[sfSigners].append(signerOuter); + } + txJson[sfSponsorSignature] = sponsorSignature; + params[jss::tx_json] = txJson; + + auto const resp = env.rpc("json", "simulate", to_string(params)); + BEAST_EXPECTS( + resp[jss::result][jss::error_message] == "Transaction should not be signed.", + resp.toStyledString()); + } { // Invalid transaction json::Value params; @@ -915,6 +958,104 @@ class Simulate_test : public beast::unit_test::Suite testTx(env, tx, validateOutput, false); } + void + testSuccessfulPreFundedSponsoredTransaction() + { + testcase("Successful pre-funded sponsored transaction"); + + using namespace jtx; + Env env(*this); + Account const alice("alice"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, sponsor); + env.close(); + + // Create the Sponsorship ledger object that pre-funds alice's fees. + env(sponsor::set_fee(sponsor, 0, XRP(100)), sponsor::SponseeAcc(alice)); + env.close(); + + auto validateOutput = [&](json::Value const& resp, json::Value const& tx) { + auto const result = resp[jss::result]; + checkBasicReturnValidity(result, tx, env.seq(alice), env.current()->fees().base); + + BEAST_EXPECT(result[jss::engine_result] == "tesSUCCESS"); + BEAST_EXPECT(result[jss::engine_result_code] == 0); + BEAST_EXPECT( + result[jss::engine_result_message] == + "The simulated transaction would have been applied."); + + // A pre-funded sponsored transaction needs no sponsor signature, + // so no SponsorSignature field should be autofilled. + json::Value txJson; + if (result.isMember(jss::tx_json)) + { + txJson = result[jss::tx_json]; + } + else + { + auto const unHexed = strUnHex(result[jss::tx_blob].asString()); + // NOLINTNEXTLINE(bugprone-unchecked-optional-access) + SerialIter sitTrans(makeSlice(*unHexed)); + txJson = STObject(std::ref(sitTrans), sfGeneric).getJson(JsonOptions::Values::None); + } + BEAST_EXPECT(!txJson.isMember(sfSponsorSignature.jsonName)); + + if (BEAST_EXPECT(result.isMember(jss::meta) || result.isMember(jss::meta_blob))) + { + json::Value const metadata = getJsonMetadata(result); + BEAST_EXPECT(metadata[sfTransactionResult.jsonName] == "tesSUCCESS"); + + // The fee is drawn from the Sponsorship ledger object, not + // from alice's balance. + if (BEAST_EXPECT(metadata.isMember(sfAffectedNodes.jsonName))) + { + bool foundSponsorship = false; + for (auto const& node : metadata[sfAffectedNodes.jsonName]) + { + if (node.isMember(sfModifiedNode.jsonName) && + node[sfModifiedNode.jsonName][sfLedgerEntryType.jsonName] == + "Sponsorship") + { + foundSponsorship = true; + auto const& finalFields = + node[sfModifiedNode.jsonName][sfFinalFields.jsonName]; + BEAST_EXPECT( + finalFields[sfFeeAmount.jsonName] == + (XRPAmount(100'000'000) - env.current()->fees().base) + .jsonClipped() + .asString()); + } + } + BEAST_EXPECT(foundSponsorship); + } + } + }; + + json::Value tx; + tx[jss::Account] = alice.human(); + tx[jss::TransactionType] = jss::AccountSet; + tx[sfDomain] = "123ABC"; + tx[sfSponsor.jsonName] = sponsor.human(); + tx[sfSponsorFlags.jsonName] = spfSponsorFee; + // Deliberately no SponsorSignature: pre-funded mode needs no + // sponsor signature. + BEAST_EXPECT(!tx.isMember(sfSponsorSignature.jsonName)); + + // test with autofill + testTx(env, tx, validateOutput); + + tx[sfSigningPubKey] = ""; + tx[sfTxnSignature] = ""; + tx[sfSequence] = env.seq(alice); + tx[sfFee] = env.current()->fees().base.jsonClipped().asString(); + + // test without autofill + testTx(env, tx, validateOutput); + + // Nothing was applied: the pre-funded fee balance is untouched. + BEAST_EXPECT(sponsor::sponsorshipFeeBalance(env, sponsor, alice) == XRP(100)); + } + void testTransactionSigningFailure() { @@ -1348,6 +1489,7 @@ class Simulate_test : public beast::unit_test::Suite testTransactionTecFailure(); testSuccessfulTransactionMultisigned(); testSuccessfulSponsoredTransactionMultisigned(); + testSuccessfulPreFundedSponsoredTransaction(); testTransactionSigningFailure(); testInvalidSingleAndMultiSigningTransaction(); testMultisignedBadPubKey(); From 7d213f2238b6d9a3e1257ac8ad9fb4f61a5bbabc Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Wed, 15 Jul 2026 12:14:26 -0400 Subject: [PATCH 2/3] fix test --- src/test/app/Sponsor_test.cpp | 67 +++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index 90002ab0b2c..50bcf94bc82 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -5987,40 +5987,59 @@ class Sponsor_test : public beast::unit_test::Suite BEAST_EXPECT( env.le(keylet::sponsorship(sponsor, alice))->getFieldU32(sfRemainingOwnerCount) == 1); - // Exhaust the budget (a zero update makes the field absent). - env(sponsor::set_reserve(sponsor, 0, 0), sponsor::SponseeAcc(alice)); - env.close(); - BEAST_EXPECT( - !env.le(keylet::sponsorship(sponsor, alice))->isFieldPresent(sfRemainingOwnerCount)); - // A second unsponsored check. auto const seq2 = env.seq(alice); env(check::create(alice, bob, XRP(1))); env.close(); auto const checkId2 = keylet::check(alice, seq2).key; + // A second co-signed transfer exhausts the budget 1 -> 0 (a + // SponsorshipSet update to an all-zero-budget object is itself + // rejected by SponsorshipSet::preclaim's hasSponsorshipBudget check, + // so the only way to reach a zero RemainingOwnerCount here is by + // spending it down via SponsorshipTransfer). + env(sponsor::transfer(alice, tfSponsorshipCreate, checkId2), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + env.close(); + + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 2); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 2); + BEAST_EXPECT( + env.le(keylet::sponsorship(sponsor, alice))->getFieldU32(sfRemainingOwnerCount) == 0); + + // A third unsponsored check. + auto const seq3 = env.seq(alice); + env(check::create(alice, bob, XRP(1))); + env.close(); + auto const checkId3 = keylet::check(alice, seq3).key; + // With the budget exhausted, the same co-signed transfer fails in // checkReserve (RemainingOwnerCount 0 < ownerCountDelta 1) with // tecINSUFFICIENT_RESERVE, even though the sponsor's own balance is // ample: an existing Sponsorship object's budget always bounds // reserve sponsorship for that sponsor/sponsee pair. - env(sponsor::transfer(alice, tfSponsorshipCreate, checkId2), + env(sponsor::transfer(alice, tfSponsorshipCreate, checkId3), sponsor::As(sponsor, spfSponsorReserve), Sig(sfSponsorSignature, sponsor), Ter(tecINSUFFICIENT_RESERVE)); env.close(); - BEAST_EXPECT(!env.le(keylet::check(alice, seq2))->isFieldPresent(sfSponsor)); - BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); - BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + BEAST_EXPECT(!env.le(keylet::check(alice, seq3))->isFieldPresent(sfSponsor)); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 2); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 2); } void testZeroValueUpdateOnAbsentFields() { - // Updating a Sponsorship with all-zero values when the optional fields - // are already absent must succeed: STObject::makeFieldAbsent - // early-returns on fields that are already not present. + // Updating a Sponsorship with all-zero FeeAmount/MaxFee when those + // optional fields are already absent must succeed: STObject:: + // makeFieldAbsent early-returns on fields that are already not + // present. A positive RemainingOwnerCount keeps the object clear of + // SponsorshipSet::preclaim's hasSponsorshipBudget invariant, which + // rejects any create/update leaving both FeeAmount and + // RemainingOwnerCount non-positive. testcase("Zero-value SponsorshipSet update on absent fields"); using namespace test::jtx; @@ -6032,22 +6051,24 @@ class Sponsor_test : public beast::unit_test::Suite auto const baseFee = env.current()->fees().base; - // Create a minimal sponsorship: all-zero values leave FeeAmount, - // MaxFee, and RemainingOwnerCount absent. - env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)), sponsor::SponseeAcc(alice)); + // Create a minimal sponsorship: a positive reserve budget, with + // all-zero FeeAmount/MaxFee left absent. + env(sponsor::set(sponsor, 0, 5, XRP(0), XRP(0)), sponsor::SponseeAcc(alice)); env.close(); auto sle = env.le(keylet::sponsorship(sponsor, alice)); BEAST_EXPECT(sle); + if (!sle) + return; BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount)); BEAST_EXPECT(!sle->isFieldPresent(sfMaxFee)); - BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); + BEAST_EXPECT(sle->getFieldU32(sfRemainingOwnerCount) == 5); - // Submit the identical all-zero update again: fields are already - // absent, so each makeFieldAbsent call is a no-op and the update - // succeeds. + // Submit the identical all-zero FeeAmount/MaxFee update again (with + // the same reserve budget): those fields are already absent, so each + // makeFieldAbsent call is a no-op and the update succeeds. auto const sponsorBalance = env.balance(sponsor); - env(sponsor::set(sponsor, 0, 0, XRP(0), XRP(0)), + env(sponsor::set(sponsor, 0, 5, XRP(0), XRP(0)), sponsor::SponseeAcc(alice), Fee(baseFee), Ter(tesSUCCESS)); @@ -6055,9 +6076,11 @@ class Sponsor_test : public beast::unit_test::Suite sle = env.le(keylet::sponsorship(sponsor, alice)); BEAST_EXPECT(sle); + if (!sle) + return; BEAST_EXPECT(!sle->isFieldPresent(sfFeeAmount)); BEAST_EXPECT(!sle->isFieldPresent(sfMaxFee)); - BEAST_EXPECT(!sle->isFieldPresent(sfRemainingOwnerCount)); + BEAST_EXPECT(sle->getFieldU32(sfRemainingOwnerCount) == 5); // Only the fee was charged. BEAST_EXPECT(env.balance(sponsor) == sponsorBalance - drops(baseFee)); } From c27ae3c7d459ba754a79c82f9075176bea15e2e4 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Wed, 15 Jul 2026 12:14:46 -0400 Subject: [PATCH 3/3] fix clang-tidy issues --- src/test/app/Sponsor_test.cpp | 2 +- src/test/app/TxQ_test.cpp | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index 50bcf94bc82..cf07a8e13f4 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -6538,7 +6538,7 @@ class Sponsor_test : public beast::unit_test::Suite env(pay(gw, alice, usd(500))); env.close(); - AMM amm(env, alice, XRP(100), usd(100)); + AMM const amm(env, alice, XRP(100), usd(100)); // rogue is never funded; it only supplies a real signing keypair. Account const rogue("rogue"); diff --git a/src/test/app/TxQ_test.cpp b/src/test/app/TxQ_test.cpp index 53cc051a841..32a5d83923a 100644 --- a/src/test/app/TxQ_test.cpp +++ b/src/test/app/TxQ_test.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include #include