Merge branch 'xrplf/sponsor' of https://github.com/XRPLF/rippled into mvadari/sponsor/reserve-context

This commit is contained in:
Mayukha Vadari
2026-07-07 13:58:39 -04:00
10 changed files with 491 additions and 88 deletions

View File

@@ -1170,7 +1170,7 @@ TRANSACTION(ttCONFIDENTIAL_MPT_CLAWBACK, 89, ConfidentialMPTClawback,
TRANSACTION(ttSPONSORSHIP_TRANSFER, 90, SponsorshipTransfer,
Delegation::NotDelegable,
featureSponsor,
MayModifyVault,
NoPriv,
({
{sfObjectID, SoeOptional},
{sfSponsee, SoeOptional},

View File

@@ -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.

View File

@@ -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())
{

View File

@@ -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:

View File

@@ -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

View File

@@ -4,6 +4,7 @@
#include <xrpl/ledger/ApplyView.h>
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/CredentialHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/protocol/AccountID.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
@@ -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;
}

View File

@@ -7,6 +7,7 @@
#include <xrpl/ledger/helpers/AccountRootHelpers.h>
#include <xrpl/ledger/helpers/CredentialHelpers.h> // IWYU pragma: keep
#include <xrpl/ledger/helpers/DirectoryHelpers.h>
#include <xrpl/ledger/helpers/SponsorHelpers.h>
#include <xrpl/protocol/Feature.h>
#include <xrpl/protocol/Indexes.h>
#include <xrpl/protocol/Keylet.h>
@@ -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_)

View File

@@ -5,6 +5,7 @@
#include <test/jtx/amount.h>
#include <test/jtx/batch.h>
#include <test/jtx/check.h>
#include <test/jtx/credentials.h>
#include <test/jtx/delegate.h>
#include <test/jtx/deposit.h>
#include <test/jtx/did.h>
@@ -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

View File

@@ -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;

View File

@@ -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<bool> 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<AccountID> const& sponsor) {
if (sponsored)
return sponsor.has_value();
return !sponsor.has_value();
auto sponsoredMatchesFilter = [&sponsoredFilter](std::optional<AccountID> 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<AccountID> const nftSponsor = currentPage->isFieldPresent(sfSponsor)
? currentPage->getAccountID(sfSponsor)
: std::optional<AccountID>(std::nullopt);
if (!sponsoredMatchesFilter(sponsored, nftSponsor))
canAppendNFT = false;
}
std::optional<AccountID> const nftSponsor = currentPage->isFieldPresent(sfSponsor)
? currentPage->getAccountID(sfSponsor)
: std::optional<AccountID>(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<AccountID> 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<bool> 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);