diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index 97ff0f191e..951fe4a172 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1170,7 +1170,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback, TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer, Delegation::NotDelegable, featureSponsor, - MayModifyVault, + NoPriv, ({ {sfObjectID, SoeOptional}, {sfSponsee, SoeOptional}, diff --git a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h index 03bc4d4e38..bc27f1603b 100644 --- a/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h +++ b/include/xrpl/protocol_autogen/transactions/SponsorshipTransfer.h @@ -21,7 +21,7 @@ class SponsorshipTransferBuilder; * Type: ttSPONSORSHIP_TRANSFER (90) * Delegable: Delegation::NotDelegable * Amendment: featureSponsor - * Privileges: MayModifyVault + * Privileges: NoPriv * * Immutable wrapper around STTx providing type-safe field access. * Use SponsorshipTransferBuilder to construct new transactions. diff --git a/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp b/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp index ba4725560c..fcb53e7c8f 100644 --- a/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp +++ b/src/libxrpl/tx/invariants/PermissionedDomainInvariant.cpp @@ -151,18 +151,6 @@ ValidPermissionedDomain::finalize( } return true; } - case ttSPONSORSHIP_TRANSFER: { - if (sleStatus_.empty()) - return true; - - if (sleStatus_[0].isDelete) - { - JLOG(j.fatal()) << "Invariant failed: domain object " - "deleted by SponsorshipTransfer"; - return false; - } - return check(sleStatus_[0], j); - } default: { if (!sleStatus_.empty()) { diff --git a/src/libxrpl/tx/invariants/VaultInvariant.cpp b/src/libxrpl/tx/invariants/VaultInvariant.cpp index 31d7de58fc..a9ba0ec874 100644 --- a/src/libxrpl/tx/invariants/VaultInvariant.cpp +++ b/src/libxrpl/tx/invariants/VaultInvariant.cpp @@ -222,7 +222,10 @@ ValidVault::deltaAssetsTxAccount(STTx const& tx, XRPAmount fee) const if (!ret.has_value() || !vaultAsset.native()) return ret; - if (auto const delegate = tx[~sfDelegate]; delegate.has_value() && *delegate != tx[sfAccount]) + // Only add the fee back if tx[sfAccount] actually paid it. When the fee is + // paid by someone else (a delegate or a fee sponsor), the + // account's XRP balance moved only by the vault amount. + if (tx.getFeePayerID() != tx[sfAccount]) return ret; ret->delta += fee.drops(); @@ -1045,8 +1048,6 @@ ValidVault::finalize( case ttLOAN_SET: case ttLOAN_MANAGE: case ttLOAN_PAY: - case ttSPONSORSHIP_TRANSFER: - // TBD for loans; SponsorshipTransfer may update a vault's sfSponsor return true; default: diff --git a/src/libxrpl/tx/transactors/account/AccountDelete.cpp b/src/libxrpl/tx/transactors/account/AccountDelete.cpp index 6cbb0ff6d3..0055fce403 100644 --- a/src/libxrpl/tx/transactors/account/AccountDelete.cpp +++ b/src/libxrpl/tx/transactors/account/AccountDelete.cpp @@ -396,9 +396,6 @@ AccountDelete::doApply() if (!isTesSuccess(ter)) return ter; - if (src->isFieldPresent(sfSponsoredOwnerCount)) - return tefINTERNAL; // LCOV_EXCL_LINE - // Transfer any XRP remaining after the fee is paid to the destination: auto const remainingBalance = src->getFieldAmount(sfBalance).xrp(); (*dst)[sfBalance] = (*dst)[sfBalance] + remainingBalance; @@ -410,11 +407,14 @@ AccountDelete::doApply() auto const sponsorID = src->getAccountID(sfSponsor); auto sponsorSle = view().peek(keylet::account(sponsorID)); - if (!sponsorSle || !sponsorSle->isFieldPresent(sfSponsoringAccountCount)) + if (!sponsorSle) return tefINTERNAL; // LCOV_EXCL_LINE auto const sponsoringAccountCount = sponsorSle->getFieldU32(sfSponsoringAccountCount); + XRPL_ASSERT( + sponsoringAccountCount != 0, + "xrpl::AccountDelete::doApply : sponsoring account count is present"); if (sponsoringAccountCount == 0) { // sanity check diff --git a/src/libxrpl/tx/transactors/credentials/CredentialAccept.cpp b/src/libxrpl/tx/transactors/credentials/CredentialAccept.cpp index 150710b4e0..4bea493d37 100644 --- a/src/libxrpl/tx/transactors/credentials/CredentialAccept.cpp +++ b/src/libxrpl/tx/transactors/credentials/CredentialAccept.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -93,12 +94,6 @@ CredentialAccept::doApply() if (!sleSubject || !sleIssuer) return tefINTERNAL; // LCOV_EXCL_LINE - { - STAmount const reserve{accountReserve(view(), sleSubject, j_, {.ownerCountDelta = 1})}; - if (preFeeBalance_ < reserve) - return tecINSUFFICIENT_RESERVE; - } - auto const credType(ctx_.tx[sfCredentialType]); Keylet const credentialKey = keylet::credential(accountID_, issuer, credType); auto const sleCred = view().peek(credentialKey); // Checked in preclaim() @@ -114,10 +109,26 @@ CredentialAccept::doApply() } sleCred->setFieldU32(sfFlags, lsfAccepted); - view().update(sleCred); - decreaseOwnerCount(view(), ReserveContext::makeFromAccount(view(), sleIssuer, nullptr), 1, j_); - increaseOwnerCount(view(), ReserveContext::makeFromAccount(view(), sleSubject, nullptr), 1, j_); + auto const txReserveContext = ctx_.getApplyViewContext().txReserveContext; + + // Release the original creation sponsor from the credential (it covered + // the issuer's reserve), then assign the accept tx's sponsor (if any) so + // the credential reflects whoever is now covering the subject's reserve. + decreaseOwnerCountForObject(view(), sleIssuer, sleCred, 1, j_); + removeSponsorFromLedgerEntry(sleCred); + + if (view().rules().enabled(featureSponsor) || view().rules().enabled(fixCleanup3_3_0)) + { + if (auto const ret = checkReserve( + ctx_.getApplyViewContext(), sleSubject, preFeeBalance_, {.ownerCountDelta = 1}, j_); + !isTesSuccess(ret)) + return ret; + } + + addSponsorToLedgerEntry(sleCred, txReserveContext.sponsorSle); + increaseOwnerCount(view(), txReserveContext, 1, j_); + view().update(sleCred); return tesSUCCESS; } diff --git a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp index f70af71c95..d0b9598d75 100644 --- a/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp +++ b/src/libxrpl/tx/transactors/credentials/CredentialCreate.cpp @@ -7,6 +7,7 @@ #include #include // IWYU pragma: keep #include +#include #include #include #include @@ -130,11 +131,10 @@ CredentialCreate::doApply() if (!sleIssuer) return tefINTERNAL; // LCOV_EXCL_LINE - { - STAmount const reserve{accountReserve(view(), sleIssuer, j_, {.ownerCountDelta = 1})}; - if (preFeeBalance_ < reserve) - return tecINSUFFICIENT_RESERVE; - } + if (auto const ret = checkReserve( + ctx_.getApplyViewContext(), sleIssuer, preFeeBalance_, {.ownerCountDelta = 1}, j_); + !isTesSuccess(ret)) + return ret; sleCred->setAccountID(sfSubject, subject); sleCred->setAccountID(sfIssuer, accountID_); @@ -152,8 +152,9 @@ CredentialCreate::doApply() return tecDIR_FULL; sleCred->setFieldU64(sfIssuerNode, *page); - increaseOwnerCount( - view(), ReserveContext::makeFromAccount(view(), sleIssuer, nullptr), 1, j_); + auto const txReserveContext = ctx_.getApplyViewContext().txReserveContext; + increaseOwnerCount(view(), txReserveContext, 1, j_); + addSponsorToLedgerEntry(sleCred, txReserveContext.sponsorSle); } if (subject == accountID_) diff --git a/src/test/app/Sponsor_test.cpp b/src/test/app/Sponsor_test.cpp index bc3abb42df..431df8b115 100644 --- a/src/test/app/Sponsor_test.cpp +++ b/src/test/app/Sponsor_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -2607,6 +2608,76 @@ public: BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 1); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + + // the newly-created trust line carries the sponsor on + // bob's side, and no sponsor on the counterparty side + bool const bobLow = bob.id() < gw.id(); + auto const lineSle = env.le(keylet::trustLine(bob, gw, usd.currency)); + BEAST_EXPECT(lineSle); + if (lineSle) + { + BEAST_EXPECT( + lineSle->getAccountID(bobLow ? sfLowSponsor : sfHighSponsor) == + sponsor.id()); + BEAST_EXPECT( + !lineSle->isFieldPresent(bobLow ? sfHighSponsor : sfLowSponsor)); + } + }); + } + + // MPT sponsor: cashing an MPT check creates a sponsored MPToken for + // the casher. Unlike the trust-line path, this returns + // tecINSUFFICIENT_RESERVE (not tecNO_LINE_INSUF_RESERVE) on shortfall. + { + Env env{*this, testableAmendments()}; + env.fund(XRP(10000), bob, sponsor, sponsor2); + env.close(); + + MPTTester mptGw(env, gw, {.holders = {alice}}); + mptGw.create({.ownerCount = 1, .holderCount = 0, .flags = tfMPTCanTransfer}); + mptGw.authorize({.account = alice}); + auto const mpt = mptGw["MPT"]; + env(pay(gw, alice, mpt(10'000))); + env.close(); + + // CheckCreate (alice -> bob) paying the MPT + uint32_t seq2 = 0; + testEachSponsorship( + env, + cosigning, + sponsor, + alice, + 1, + 1, + tecINSUFFICIENT_RESERVE, + [&](Env& env, auto const& submit) { + seq2 = env.seq(alice); + submit(check::create(alice, bob, mpt(1))); + }); + + auto const checkKeylet = keylet::check(alice, seq2); + BEAST_EXPECT(env.le(checkKeylet)->getAccountID(sfSponsor) == sponsor.id()); + BEAST_EXPECT(ownerCount(env, bob) == 0); + + // CheckCash by bob (no MPToken yet) creates a sponsored MPToken + testEachSponsorship( + env, + cosigning, + sponsor, + bob, + 1, + 1, + tecINSUFFICIENT_RESERVE, + [&](Env& env, auto const& submit) { + submit(check::cash(bob, checkKeylet.key, mpt(1))); + }, + [&]() { + BEAST_EXPECT(ownerCount(env, bob) == 1); // MPToken + BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + BEAST_EXPECT( + env.le(keylet::mptoken(mptGw.issuanceID(), bob))->getAccountID(sfSponsor) == + sponsor.id()); }); } } @@ -2639,8 +2710,11 @@ public: submit(delegate::set(alice, bob, {"Payment"})); }); - // transfer sponsor + // the created Delegate object carries the sponsor auto const keylet = keylet::delegate(alice, bob); + BEAST_EXPECT(env.le(keylet)->getAccountID(sfSponsor) == sponsor.id()); + + // transfer sponsor if (cosigning) { env(sponsor::transfer(alice, tfSponsorshipReassign, keylet.key), @@ -2662,6 +2736,7 @@ public: BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor2) == 1); + BEAST_EXPECT(env.le(keylet)->getAccountID(sfSponsor) == sponsor2.id()); // delete env(delegate::set(alice, bob, {})); @@ -2699,8 +2774,11 @@ public: tecINSUFFICIENT_RESERVE, [&](Env& env, auto const& submit) { submit(deposit::auth(alice, sponsor)); }); - // transfer sponsor + // the created DepositPreauth object carries the sponsor auto const keylet = keylet::depositPreauth(alice, sponsor); + BEAST_EXPECT(env.le(keylet)->getAccountID(sfSponsor) == sponsor.id()); + + // transfer sponsor if (cosigning) { env(sponsor::transfer(alice, tfSponsorshipReassign, keylet.key), @@ -2731,6 +2809,7 @@ public: BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor2) == 1); + BEAST_EXPECT(env.le(keylet)->getAccountID(sfSponsor) == sponsor2.id()); // DepositPreauthDelete env(deposit::unauth(alice, sponsor)); @@ -2768,6 +2847,7 @@ public: // Cover sfUnauthorizeCredentials cleanup for a sponsored preauth object. BEAST_EXPECT(env.le(preauthKeylet)); + BEAST_EXPECT(env.le(preauthKeylet)->getAccountID(sfSponsor) == sponsor.id()); BEAST_EXPECT(ownerCount(env, alice) == 1); BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); @@ -2782,6 +2862,171 @@ public: } } + void + testCredential(bool cosigning) + { + testcase("Credential"); + using namespace test::jtx; + Account const alice("alice"); + Account const bob("bob"); + Account const sponsor("sponsor"); + Account const sponsor2("sponsor2"); + auto const credType = std::string("test"); + + // Self-issued credential: alice creates for herself, sponsor covers reserve + { + Env env{*this, testableAmendments()}; + env.fund(XRP(1000000), alice, bob, sponsor, sponsor2); + env.close(); + + testEachSponsorship( + env, + cosigning, + sponsor, + alice, + 1, + 1, + tecINSUFFICIENT_RESERVE, + [&](Env& env, auto const& submit) { + submit(credentials::create(alice, alice, credType)); + }); + + auto const credKeylet = credentials::keylet(alice, alice, credType); + BEAST_EXPECT(env.le(credKeylet)->getAccountID(sfSponsor) == sponsor.id()); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + + // Transfer sponsor + if (cosigning) + { + env(sponsor::transfer(alice, tfSponsorshipReassign, credKeylet.key), + sponsor::As(sponsor2, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor2)); + env.close(); + } + else + { + env(sponsor::set_reserve(sponsor2, 0, 1), sponsor::SponseeAcc(alice)); + env.close(); + env(sponsor::transfer(alice, tfSponsorshipReassign, credKeylet.key), + sponsor::As(sponsor2, spfSponsorReserve)); + env.close(); + } + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor2) == 1); + BEAST_EXPECT(env.le(credKeylet)->getAccountID(sfSponsor) == sponsor2.id()); + + // CredentialDelete + env(credentials::deleteCred(alice, alice, alice, credType)); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor2) == 0); + } + + // Issuer creates for subject; sponsorship transfers to subject on accept + { + Env env{*this, testableAmendments()}; + env.fund(XRP(1000000), alice, bob, sponsor); + env.close(); + + // alice (issuer) creates credential for bob (subject), sponsor covers alice's reserve + testEachSponsorship( + env, + cosigning, + sponsor, + alice, + 1, + 1, + tecINSUFFICIENT_RESERVE, + [&](Env& env, auto const& submit) { + submit(credentials::create(bob, alice, credType)); + }); + + // Before accept: alice (issuer) owns the credential + auto const credKeylet = credentials::keylet(bob, alice, credType); + BEAST_EXPECT(env.le(credKeylet)->getAccountID(sfSponsor) == sponsor.id()); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + BEAST_EXPECT(ownerCount(env, bob) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 0); + + // Bob accepts: issuer's sponsorship ends, bob becomes unsponsored owner + env(credentials::accept(bob, alice, credType)); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); // original sponsorship ended + BEAST_EXPECT(ownerCount(env, bob) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 0); // bob owns it without a sponsor + + // CredentialDelete by subject + env(credentials::deleteCred(bob, bob, alice, credType)); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 0); + BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 0); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); + } + + // Issuer creates for subject; accept tx itself is reserve-sponsored + // This exercises the checkReserve() call in CredentialAccept::doApply() + // that guards the sponsor's reserve when featureSponsor is enabled. + { + Env env{*this, testableAmendments()}; + env.fund(XRP(1000000), alice, bob, sponsor); + env.close(); + + // alice (issuer) creates credential for bob (subject) without sponsor + env(credentials::create(bob, alice, credType)); + env.close(); + + auto const credKeylet = credentials::keylet(bob, alice, credType); + BEAST_EXPECT(ownerCount(env, alice) == 1); + BEAST_EXPECT(ownerCount(env, bob) == 0); + + // Bob accepts with a sponsored reserve; the first attempt uses an + // undercapitalized sponsor and must fail with tecINSUFFICIENT_RESERVE; + // the second attempt uses a properly funded sponsor and must succeed. + testEachSponsorship( + env, + cosigning, + sponsor, + bob, + 1, + 1, + tecINSUFFICIENT_RESERVE, + [&](Env& env, auto const& submit) { + submit(credentials::accept(bob, alice, credType)); + }); + + // After successful accept: alice (issuer) no longer owns the credential, + // bob owns it and sponsor covers his reserve. + BEAST_EXPECT(ownerCount(env, alice) == 0); + BEAST_EXPECT(ownerCount(env, bob) == 1); + BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + BEAST_EXPECT(env.le(credKeylet)->getAccountID(sfSponsor) == sponsor.id()); + + // Cleanup + env(credentials::deleteCred(bob, bob, alice, credType)); + env.close(); + } + } + void testDID(bool cosigning) { @@ -2996,6 +3241,80 @@ public: env.le(keylet::trustLine(bob, gw, usd.currency))->getAccountID(sfHighSponsor) == sponsor2.id()); } + { + // IOU Escrow cancel re-creates the owner's trust line, and the + // cancel transaction's sponsor can cover that new line's reserve. + // Creating an IOU escrow moves the sender's balance to the issuer, + // so the sender can delete their now-zero trust line while the + // escrow is pending. Cancelling returns the funds, re-creating the + // line. + Env env{*this, testableAmendments()}; + + env.fund(XRP(1000000), alice, bob, gw, sponsor, sponsor2); + env.close(); + + env(fset(gw, asfAllowTrustLineLocking)); + env.close(); + + env.trust(usd(1000000), alice); + env.close(); + env(pay(gw, alice, usd(10000))); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 1); + + // alice escrows her entire balance so the trust line can be removed + auto const cancelAfter = env.now() + 100s; + auto const seq = env.seq(alice); + env(escrow::create(alice, bob, usd(10000)), + escrow::kCondition(escrow::kCb1), + escrow::kCancelTime(cancelAfter)); + env.close(); + + BEAST_EXPECT(ownerCount(env, alice) == 2); // trust line + escrow + + // alice deletes her now-zero trust line while the escrow is pending + env(trust(alice, usd(0))); + env.close(); + BEAST_EXPECT(!env.le(keylet::trustLine(alice, gw, usd.currency))); + BEAST_EXPECT(ownerCount(env, alice) == 1); // just the escrow + + // advance the ledger past the cancel time + for (; env.now() < cancelAfter; env.close()) + { + } + + // EscrowCancel by alice re-creates her trust line; the sponsor + // covers the new line's reserve. On the insufficient-reserve + // attempt the whole transaction rolls back, leaving the escrow + // intact to be cancelled on the success attempt. + bool const aliceLow = alice.id() < gw.id(); + testEachSponsorship( + env, + cosigning, + sponsor, + alice, + 1, + 1, + tecNO_LINE_INSUF_RESERVE, + [&](Env& env, auto const& submit) { submit(escrow::cancel(alice, alice, seq)); }, + [&]() { + BEAST_EXPECT(!env.le(keylet::escrow(alice, seq))); + auto const trustSle = env.le(keylet::trustLine(alice, gw, usd.currency)); + BEAST_EXPECT(trustSle); + if (trustSle) + { + BEAST_EXPECT( + trustSle->getAccountID(aliceLow ? sfLowSponsor : sfHighSponsor) == + sponsor.id()); + BEAST_EXPECT( + !trustSle->isFieldPresent(aliceLow ? sfHighSponsor : sfLowSponsor)); + } + BEAST_EXPECT(ownerCount(env, alice) == 1); // re-created trust line + BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); + BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + }); + } { // MPT Escrow Env env{*this, testableAmendments()}; @@ -3021,19 +3340,33 @@ public: BEAST_EXPECT(ownerCount(env, bob) == 0); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); - // finish Escrow - env(escrow::finish(bob, alice, seq), - escrow::kCondition(escrow::kCb1), - escrow::kFulfillment(escrow::kFb1), - sponsor::As(sponsor, spfSponsorReserve), - Sig(sfSponsorSignature, sponsor), - Fee(XRP(1))); - env.close(); + // finish Escrow: bob has no MPToken, so finishing creates one and + // the finish transaction's sponsor covers its reserve. The MPT + // create-token path returns tecINSUFFICIENT_RESERVE (not + // tecNO_LINE_INSUF_RESERVE) when the sponsor is underfunded. + testEachSponsorship( + env, + cosigning, + sponsor, + bob, + 1, + 1, + tecINSUFFICIENT_RESERVE, + [&](Env& env, auto const& submit) { + submit( + escrow::finish(bob, alice, seq), + escrow::kCondition(escrow::kCb1), + escrow::kFulfillment(escrow::kFb1), + Fee(XRP(1))); + }); BEAST_EXPECT(ownerCount(env, alice) == 1); BEAST_EXPECT(ownerCount(env, bob) == 1); BEAST_EXPECT(sponsoredOwnerCount(env, bob) == 1); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 1); + BEAST_EXPECT( + env.le(keylet::mptoken(mptGw.issuanceID(), bob))->getAccountID(sfSponsor) == + sponsor.id()); } // A sponsored EscrowCreate must still verify that the source @@ -3312,6 +3645,9 @@ public: submit(paychan::create(alice, bob, XRP(100), settleDelay, pk)); }); + // the created PayChannel object carries the sponsor + BEAST_EXPECT(env.le(Keylet(ltPAYCHAN, chan))->getAccountID(sfSponsor) == sponsor.id()); + // transfer sponsor if (cosigning) { @@ -3334,6 +3670,7 @@ public: BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor2) == 1); + BEAST_EXPECT(env.le(Keylet(ltPAYCHAN, chan))->getAccountID(sfSponsor) == sponsor2.id()); env.close(env.now() + settleDelay); // PayChanClaim (delete PayChan) @@ -3414,6 +3751,9 @@ public: tecINSUFFICIENT_RESERVE, [&](Env& env, auto const& submit) { submit(signers(alice, 1, {{bob, 1}})); }); + // the created SignerList object carries the sponsor + BEAST_EXPECT(env.le(keylet::signerList(alice))->getAccountID(sfSponsor) == sponsor.id()); + // transfer sponsor if (cosigning) { @@ -3449,6 +3789,7 @@ public: BEAST_EXPECT(sponsoredOwnerCount(env, alice) == 1); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor) == 0); BEAST_EXPECT(sponsoringOwnerCount(env, sponsor2) == 1); + BEAST_EXPECT(env.le(keylet::signerList(alice))->getAccountID(sfSponsor) == sponsor2.id()); // Delete env(signers(alice, NoneT())); @@ -3897,12 +4238,6 @@ public: auto const usd = gw["usd"]; - // Create sponsorship allowing reserve sponsoring - env(sponsor::set(sponsor, 0, 100, XRP(100)), - sponsor::SponseeAcc(alice), - Ter(tesSUCCESS)); - env.close(); - // Create a trust line for alice env(trust(alice, usd(1000))); env.close(); @@ -3962,6 +4297,37 @@ public: BEAST_EXPECT( sponsorSleAfter->getFieldU32(sfSponsoringAccountCount) == sponsoringAccountCount); } + + { + // Account with sponsored objects should be deletable + Env env{*this, testableAmendments()}; + env.fund(XRP(1000000), alice, bob, sponsor); + env.close(); + + // Create sponsored delegate (a non-deletion-blocker) + env(deposit::auth(alice, bob), + sponsor::As(sponsor, spfSponsorReserve), + Sig(sfSponsorSignature, sponsor)); + + { + auto const sponsorSle = env.le(keylet::account(sponsor)); + auto const aliceSle = env.le(keylet::account(alice)); + BEAST_EXPECT(sponsorSle->at(sfSponsoringOwnerCount) == 1); + BEAST_EXPECT(aliceSle->at(sfOwnerCount) == 1); + BEAST_EXPECT(aliceSle->at(sfSponsoredOwnerCount) == 1); + } + + incLgrSeqForAccDel(env, alice); + + // AccountDelete should succeed + { + auto const requiredFee = drops(env.current()->fees().increment); + env(acctdelete(alice, bob), Fee(requiredFee), Ter(tesSUCCESS)); + BEAST_EXPECT(!env.le(keylet::account(alice))); + auto const sponsorSle = env.le(keylet::account(sponsor)); + BEAST_EXPECT(sponsorSle->at(sfSponsoringOwnerCount) == 0); + } + } } void @@ -4411,6 +4777,7 @@ public: testRequireFlag(); testSponsorReserveSimple(cosigning); testCheck(cosigning); + testCredential(cosigning); testDelegate(cosigning); testDepositPreauth(cosigning); testEscrow(cosigning); @@ -4565,6 +4932,56 @@ public: BEAST_EXPECT(issuerSponsoredAfter == issuerSponsoredBefore); } + void + testFeeSponsoredVaultInvariant() + { + // The ValidVault invariant checks that the vault's balance and the + // depositor's balance change by equal amounts. For XRP vaults it adds the + // fee back into the depositor's balance change: normally the depositor + // pays the fee, so their balance drops by (deposit amount + fee), and + // adding the fee back leaves just the deposit amount to compare against + // the vault. But when a fee sponsor pays, the depositor's balance drops + // by only the deposit amount, so the fee must NOT be added back or the + // equal-amount check fails. + testcase("Fee-sponsored VaultDeposit/VaultWithdraw pass ValidVault invariant"); + using namespace test::jtx; + + Env env{*this, testableAmendments()}; + Account const alice("alice"); + Account const sponsor("sponsor"); + env.fund(XRP(10000), alice, sponsor); + env.close(); + + PrettyAsset const xrpAsset{xrpIssue(), 1'000'000}; + Vault const vault{env}; + auto [vaultTx, vaultKeylet] = vault.create({.owner = alice, .asset = xrpAsset}); + env(vaultTx); + env.close(); + + // Control: the same deposit shape, unsponsored, succeeds. + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(100)}), + Ter(tesSUCCESS)); + env.close(); + + // Fee-sponsored (co-signed) deposit succeeds. + env(vault.deposit({.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(100)}), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + + // The same helper (deltaAssetsTxAccount) drives the withdraw path, so a + // fee-sponsored withdrawal back to the depositor's own account also + // passes on the destination side. + env(vault.withdraw({.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(50)}), + Fee(XRP(1)), + sponsor::As(sponsor, spfSponsorFee), + Sig(sfSponsorSignature, sponsor), + Ter(tesSUCCESS)); + env.close(); + } + protected: void testSponsor() @@ -4602,6 +5019,8 @@ protected: testZeroBalanceSponsoredPaymentFeePayerCheck(); testTrustSetCounterpartySponsorMisroute(); + + testFeeSponsoredVaultInvariant(); } void diff --git a/src/test/jtx/Env.h b/src/test/jtx/Env.h index 1877147d4b..04cbce953a 100644 --- a/src/test/jtx/Env.h +++ b/src/test/jtx/Env.h @@ -562,19 +562,19 @@ public: ownerCount(Account const& account) const; /** Return the number of sponsored objects owned by an account. - * Returns 0 if the account does not exist. + * Throws if the account does not exist. */ [[nodiscard]] std::uint32_t sponsoredOwnerCount(Account const& account) const; /** Return the number of sponsoring objects owned by an account. - * Returns 0 if the account does not exist. + * Throws if the account does not exist. */ [[nodiscard]] std::uint32_t sponsoringOwnerCount(Account const& account) const; /** Return the number of sponsoring accounts owned by an account. - * Returns 0 if the account does not exist. + * Throws if the account does not exist. */ [[nodiscard]] std::uint32_t sponsoringAccountCount(Account const& account) const; diff --git a/src/xrpld/rpc/handlers/account/AccountObjects.cpp b/src/xrpld/rpc/handlers/account/AccountObjects.cpp index 9a016260a2..ce0ce57eb3 100644 --- a/src/xrpld/rpc/handlers/account/AccountObjects.cpp +++ b/src/xrpld/rpc/handlers/account/AccountObjects.cpp @@ -33,8 +33,7 @@ namespace xrpl { @param dirIndex Begin gathering account objects from this directory. @param entryIndex Begin gathering objects from this directory node. @param limit Maximum number of objects to find. - @param hasSponsoredFilter Whether to filter by sponsored objects. - @param sponsored Whether filtered objects should be sponsored. + @param sponsoredFilter If set, only return objects whose sponsored state matches the value. @param jvResult A JSON result that holds the request objects. */ bool @@ -45,8 +44,7 @@ getAccountObjects( uint256 dirIndex, uint256 entryIndex, std::uint32_t const limit, - bool const hasSponsoredFilter, - bool const sponsored, + std::optional const sponsoredFilter, json::Value& jvResult) { // check if dirIndex is valid @@ -59,11 +57,10 @@ getAccountObjects( return it != typeFilter.end(); }; - auto sponsoredMatchesFilter = [](bool const sponsored, - std::optional const& sponsor) { - if (sponsored) - return sponsor.has_value(); - return !sponsor.has_value(); + auto sponsoredMatchesFilter = [&sponsoredFilter](std::optional const& sponsor) { + if (!sponsoredFilter.has_value()) + return true; + return sponsor.has_value() == *sponsoredFilter; }; // if dirIndex != 0, then all NFTs have already been returned. only @@ -104,15 +101,10 @@ getAccountObjects( while (currentPage) { - bool canAppendNFT = true; - if (hasSponsoredFilter) - { - std::optional const nftSponsor = currentPage->isFieldPresent(sfSponsor) - ? currentPage->getAccountID(sfSponsor) - : std::optional(std::nullopt); - if (!sponsoredMatchesFilter(sponsored, nftSponsor)) - canAppendNFT = false; - } + std::optional const nftSponsor = currentPage->isFieldPresent(sfSponsor) + ? currentPage->getAccountID(sfSponsor) + : std::optional(std::nullopt); + bool const canAppendNFT = sponsoredMatchesFilter(nftSponsor); if (canAppendNFT) jvObjects.append(currentPage->getJson(JsonOptions::Values::None)); auto const npm = (*currentPage)[~sfNextPageMin]; @@ -232,7 +224,7 @@ getAccountObjects( }; std::optional const sponsor = getSponsor(); - if (hasSponsoredFilter && !sponsoredMatchesFilter(sponsored, sponsor)) + if (!sponsoredMatchesFilter(sponsor)) canAppend = false; if (canAppend) @@ -383,27 +375,18 @@ doAccountObjects(RPC::JsonContext& context) return RPC::invalidFieldError(jss::marker); } - bool const hasSponsoredFilter = params.isMember(jss::sponsored); - bool sponsored = false; - if (hasSponsoredFilter) + std::optional sponsoredFilter; + if (params.isMember(jss::sponsored)) { auto const& sponsoredJv = params[jss::sponsored]; if (!sponsoredJv.isBool()) return RPC::expectedFieldError(jss::sponsored, "boolean"); - sponsored = sponsoredJv.asBool(); + sponsoredFilter = sponsoredJv.asBool(); } if (!getAccountObjects( - *ledger, - accountID, - typeFilter, - dirIndex, - entryIndex, - limit, - hasSponsoredFilter, - sponsored, - result)) + *ledger, accountID, typeFilter, dirIndex, entryIndex, limit, sponsoredFilter, result)) return RPC::invalidFieldError(jss::marker); result[jss::account] = toBase58(accountID);