feat: Add checks to block reserve sponsorship on OOS txs (#7605)

This commit is contained in:
Mayukha Vadari
2026-06-30 12:39:09 -04:00
committed by GitHub
parent 12cafc87db
commit 5bb979b2e5
4 changed files with 194 additions and 113 deletions

View File

@@ -35,6 +35,7 @@
#include <xrpl/protocol/SystemParameters.h>
#include <xrpl/protocol/TER.h>
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/TxFormats.h>
#include <xrpl/protocol/TxMeta.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/server/LoadFeeTrack.h>
@@ -202,6 +203,46 @@ preflight1Sponsor(PreflightContext const& ctx, AccountID const& id)
JLOG(ctx.j.debug()) << "preflight1: invalid sponsor flags";
return temINVALID_FLAG;
}
// Reserve sponsorship is only permitted for an explicit allow-list of
// transaction types, for v1. All other tx types reject spfSponsorReserve here.
if ((sponsorFlags & spfSponsorReserve) != 0u)
{
static std::unordered_set<TxType> const kReserveSponsorAllowed = {
// Explicitly allow-listed for v1.
ttDELEGATE_SET,
ttDEPOSIT_PREAUTH,
ttPAYMENT,
ttSIGNER_LIST_SET,
ttCHECK_CANCEL,
ttCHECK_CASH,
ttCHECK_CREATE,
ttESCROW_CANCEL,
ttESCROW_CREATE,
ttESCROW_FINISH,
ttPAYCHAN_CLAIM,
ttPAYCHAN_CREATE,
ttPAYCHAN_FUND,
ttCLAWBACK,
ttMPTOKEN_AUTHORIZE,
ttMPTOKEN_ISSUANCE_CREATE,
ttMPTOKEN_ISSUANCE_DESTROY,
ttMPTOKEN_ISSUANCE_SET,
ttTRUST_SET,
ttCREDENTIAL_ACCEPT,
ttCREDENTIAL_CREATE,
ttCREDENTIAL_DELETE,
ttACCOUNT_SET,
ttREGULAR_KEY_SET,
ttSPONSORSHIP_TRANSFER,
};
if (!kReserveSponsorAllowed.contains(ctx.tx.getTxnType()))
{
JLOG(ctx.j.debug())
<< "preflight1: spfSponsorReserve not allowed for this transaction type";
return temINVALID_FLAG;
}
}
}
else
{

View File

@@ -16,7 +16,6 @@
#include <xrpl/protocol/TxFlags.h>
#include <xrpl/protocol/XRPAmount.h>
#include <xrpl/tx/Transactor.h>
#include <xrpl/tx/transactors/oracle/OracleSet.h>
#include <bit>
#include <cstdint>
@@ -123,29 +122,15 @@ getLedgerEntryOwner(ReadView const& view, T const& sle, AccountID const& account
{
switch (sle->getType())
{
case ltNFTOKEN_OFFER:
case ltORACLE:
case ltPERMISSIONED_DOMAIN:
case ltVAULT:
case ltLOAN_BROKER:
return sle->getAccountID(sfOwner);
case ltCHECK:
case ltDID:
case ltTICKET:
case ltOFFER:
case ltXCHAIN_OWNED_CLAIM_ID:
case ltXCHAIN_OWNED_CREATE_ACCOUNT_CLAIM_ID:
case ltESCROW:
case ltPAYCHAN:
case ltMPTOKEN:
case ltDELEGATE:
case ltBRIDGE:
case ltDEPOSIT_PREAUTH:
return sle->getAccountID(sfAccount);
case ltMPTOKEN_ISSUANCE:
return sle->getAccountID(sfIssuer);
case ltLOAN:
return sle->getAccountID(sfBorrower);
case ltSIGNER_LIST: {
auto const signerList = view.read(keylet::signers(account));
if (!signerList)
@@ -159,12 +144,6 @@ getLedgerEntryOwner(ReadView const& view, T const& sle, AccountID const& account
return sle->getAccountID(sfSubject);
return sle->getAccountID(sfIssuer);
}
case ltNFTOKEN_PAGE: {
// the upper 20 bytes of the index of ltNFTokenPage are the Owner's
// AccountID
uint256 const& key = sle->key();
return AccountID::fromVoid(key.data());
}
case ltRIPPLE_STATE: {
if (sle->isFlag(lsfHighReserve))
{
@@ -180,39 +159,12 @@ getLedgerEntryOwner(ReadView const& view, T const& sle, AccountID const& account
}
return std::nullopt;
}
case ltACCOUNT_ROOT: {
// AccountRoot is not supported for object sponsorship
return std::nullopt;
}
case ltNEGATIVE_UNL:
case ltDIR_NODE:
case ltAMENDMENTS:
case ltLEDGER_HASHES:
case ltFEE_SETTINGS:
case ltAMM:
return std::nullopt;
default:
UNREACHABLE("Object is not supported by sponsorship.");
return std::nullopt;
};
}
template <typename T>
inline std::uint32_t
getLedgerEntryOwnerCount(T const& sle)
{
switch (sle->getType())
{
case ltORACLE: {
return OracleSet::calculateOracleReserve(sle->getFieldArray(sfPriceDataSeries).size());
}
// Vaults require 2 owner counts (the vault and a pseudo-account)
case ltVAULT:
return 2;
default:
return 1;
}
};
template <typename T>
inline SF_ACCOUNT const&
getLedgerEntrySponsorField(T const& sle, AccountID const& owner)
@@ -265,7 +217,30 @@ SponsorshipTransfer::preclaim(PreclaimContext const& ctx)
if (!sle)
return tecNO_ENTRY;
auto const ownerCountDelta = getLedgerEntryOwnerCount(sle);
// v1 scope: an object is only sponsorable via SponsorshipTransfer if
// its creating transaction type is itself permitted to set
// spfSponsorReserve (the allow-list in preflight1Sponsor). Otherwise
// an Oracle / Ticket / DID / etc. could be retroactively sponsored
// even though its creating tx cannot be, leaving downstream
// transactors with no path to maintain the sponsorship invariants.
switch (sle->getType())
{
case ltDELEGATE:
case ltDEPOSIT_PREAUTH:
case ltMPTOKEN:
case ltMPTOKEN_ISSUANCE:
case ltCREDENTIAL:
case ltRIPPLE_STATE:
case ltSIGNER_LIST:
case ltCHECK:
case ltESCROW:
case ltPAYCHAN:
break;
default:
return tecNO_PERMISSION;
}
std::uint32_t const ownerCountDelta = 1;
auto const owner = getLedgerEntryOwner(ctx.view, sle, sponseeID);
if (!owner || owner != sponseeID)
@@ -450,7 +425,7 @@ SponsorshipTransfer::doApply()
if (!ownerSle)
return tefINTERNAL; // LCOV_EXCL_LINE
std::int64_t const ownerCountDelta = getLedgerEntryOwnerCount(objSle);
std::int64_t const ownerCountDelta = 1;
auto const& sponsorField = getLedgerEntrySponsorField(objSle, *ownerID);

View File

@@ -14,6 +14,7 @@
#include <test/jtx/mpt.h>
#include <test/jtx/multisign.h>
#include <test/jtx/noop.h>
#include <test/jtx/offer.h>
#include <test/jtx/paths.h>
#include <test/jtx/pay.h>
#include <test/jtx/sendmax.h>
@@ -22,10 +23,12 @@
#include <test/jtx/sponsor.h>
#include <test/jtx/ter.h>
#include <test/jtx/ticket.h>
#include <test/jtx/token.h>
#include <test/jtx/trust.h>
#include <test/jtx/txflags.h>
#include <test/jtx/vault.h>
#include <xrpl/basics/Number.h>
#include <xrpl/basics/base_uint.h>
#include <xrpl/basics/chrono.h>
#include <xrpl/basics/strHex.h>
@@ -677,6 +680,7 @@ public:
using namespace test::jtx;
Account const alice("alice");
Account const bob("bob");
Account const charlie("charlie");
Account const sponsor("sponsor");
{
@@ -715,10 +719,10 @@ public:
{
// if pre-funded value is not enough, error
Env env{*this, testableAmendments()};
env.fund(XRP(10000), alice, bob, sponsor);
env.fund(XRP(10000), alice, bob, charlie, sponsor);
env.close();
env(sponsor::set(sponsor, 0, 10, XRP(10), XRP(100)),
env(sponsor::set(sponsor, 0, 1, XRP(10), XRP(100)),
sponsor::SponseeAcc(alice),
Ter(tesSUCCESS));
env.close();
@@ -847,19 +851,21 @@ public:
Env env{*this, testableAmendments()};
Account const alice("alice");
Account const bob("bob");
Account const charlie("charlie");
Account const sponsor("sponsor");
env.fund(XRP(10000), alice, bob, sponsor);
env.close();
{
// sponsor object
env(did::set(alice),
did::Uri("uri"),
env.fund(XRP(1000), charlie);
env.close();
env(deposit::auth(alice, charlie),
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, sponsor));
env.close();
auto const keylet = keylet::did(alice);
auto const keylet = keylet::depositPreauth(alice, charlie);
env(sponsor::transfer(bob, tfSponsorshipEnd, keylet.key),
sponsor::SponseeAcc(alice),
Ter(tecNO_PERMISSION));
@@ -1451,6 +1457,73 @@ public:
Ter(tecNO_PERMISSION));
}
}
{
// existing owner objects that are outside the v1 SponsorshipTransfer
// object allow-list
Env env{*this, testableAmendments()};
Account const alice("alice");
Account const sponsor("sponsor");
env.fund(XRP(10000), alice, sponsor);
env.close();
auto const checkBlocked = [&](Account const& account, uint256 const& objectID) {
env(sponsor::transfer(account, tfSponsorshipCreate, objectID),
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, sponsor),
Ter(tecNO_PERMISSION));
env.close();
};
auto const ticketSeq = env.seq(alice);
env(ticket::create(alice, 1));
env.close();
auto const ticketID = keylet::TicketT()(alice, ticketSeq + 1).key;
BEAST_EXPECT(env.le(keylet::unchecked(ticketID)));
checkBlocked(alice, ticketID);
env(did::setValid(alice));
env.close();
auto const didKeylet = keylet::did(alice.id());
BEAST_EXPECT(env.le(didKeylet));
checkBlocked(alice, didKeylet.key);
env(token::mint(alice, 0u));
env.close();
auto const nftPageKeylet = keylet::nftpageMax(alice);
BEAST_EXPECT(env.le(nftPageKeylet));
checkBlocked(alice, nftPageKeylet.key);
Account const borrower("borrower");
env.fund(XRP(1000000), borrower);
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();
env(vault.deposit(
{.depositor = alice, .id = vaultKeylet.key, .amount = xrpAsset(1000)}));
env.close();
auto const brokerKeylet = keylet::loanbroker(alice.id(), env.seq(alice));
env(loanBroker::set(alice, vaultKeylet.key),
loanBroker::kDebtMaximum(xrpAsset(1000).value()),
loanBroker::kManagementFeeRate(TenthBips16{0}),
loanBroker::kCoverRateMinimum(TenthBips32{0}),
loanBroker::kCoverRateLiquidation(TenthBips32{0}));
env.close();
auto const loanKeylet = keylet::loan(brokerKeylet.key, 1);
env(loan::set(borrower, brokerKeylet.key, xrpAsset(100).value()),
Sig(sfCounterpartySignature, alice),
Fee(env.current()->fees().base * 2));
env.close();
BEAST_EXPECT(env.le(loanKeylet));
checkBlocked(borrower, loanKeylet.key);
}
}
void
@@ -2041,7 +2114,6 @@ public:
env.fund(XRP(10000), alice, bob, sponsor);
env.close();
// test Sufficient sponsor balance
if (cosigning)
{
adjustAccountXRPBalance(env, sponsor, reserve(env, 1) - drops(1));
@@ -3728,6 +3800,40 @@ public:
}
}
// 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}));
}
void
testSponsorReserve(bool cosigning)
{
@@ -3769,6 +3875,8 @@ protected:
testDelegatePermission();
testBatch();
testReserveSponsorGate();
}
void

View File

@@ -1535,86 +1535,43 @@ public:
}
}
// A Sponsorship object is visible to both sides, but its reserve side
// belongs only to sfOwner.
// A Sponsorship object is visible to both sides.
{
Env env(*this, testableAmendments());
Account const owner("owner");
Account const sponsee("sponsee");
Account const sponsor("sponsor");
env.fund(XRP(10000), owner, sponsee, sponsor);
env.fund(XRP(10000), owner, sponsee);
env.close();
env(sponsor::set_reserve(sponsor, 0, 100), sponsor::SponseeAcc(owner));
env(sponsor::set(owner, 0, 100, XRP(100)), sponsor::SponseeAcc(sponsee));
env.close();
env(sponsor::set(owner, 0, 100, XRP(100)),
sponsor::SponseeAcc(sponsee),
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, sponsor));
env.close();
auto const sponsorship = env.le(keylet::sponsorship(owner, sponsee));
if (!BEAST_EXPECT(sponsorship))
auto const sponsorshipKeylet = keylet::sponsorship(owner, sponsee);
if (!BEAST_EXPECT(env.le(sponsorshipKeylet)))
return;
BEAST_EXPECT(sponsorship->isFieldPresent(sfSponsor));
{
auto const resp = acctObjsSponsored(env, owner.id(), true, jss::sponsorship);
auto const resp = acctObjsSponsored(env, owner.id(), false, jss::sponsorship);
auto const& objs = resp[jss::result][jss::account_objects];
if (BEAST_EXPECT(objs.size() == 1))
BEAST_EXPECT(objs[0u][sfLedgerEntryType.jsonName] == jss::Sponsorship);
}
{
auto const resp = acctObjsSponsored(env, sponsee.id(), true, jss::sponsorship);
auto const& objs = resp[jss::result][jss::account_objects];
BEAST_EXPECT(objs.size() == 0);
}
{
auto const resp = acctObjsSponsored(env, sponsee.id(), false, jss::sponsorship);
auto const& objs = resp[jss::result][jss::account_objects];
if (BEAST_EXPECT(objs.size() == 1))
BEAST_EXPECT(objs[0u][sfLedgerEntryType.jsonName] == jss::Sponsorship);
}
}
// NFT page sponsored filter
{
// Mint an NFT for bob (creates NFT page)
env(token::mint(bob, 0));
env.close();
auto const nftPageKeylet = keylet::nftpageMax(bob);
if (!BEAST_EXPECT(env.le(nftPageKeylet)))
return;
// Sponsor the NFT page
env(sponsor::transfer(bob, tfSponsorshipCreate, nftPageKeylet.key),
sponsor::As(sponsor1, spfSponsorReserve),
Sig(sfSponsorSignature, sponsor1));
env.close();
// Verify NFT page has sponsor field
auto const nftPage = env.le(nftPageKeylet);
if (!BEAST_EXPECT(nftPage))
return;
BEAST_EXPECT(nftPage->isFieldPresent(sfSponsor));
// sponsored=true should include the sponsored NFT page
// sponsored=false should NOT include the sponsored NFT page
for (auto const sponsored : {true, false})
{
auto const resp = acctObjsSponsored(env, bob.id(), sponsored);
auto const resp = acctObjsSponsored(env, owner.id(), true, jss::sponsorship);
auto const& objs = resp[jss::result][jss::account_objects];
bool foundNFTPage = false;
for (auto const& obj : objs)
{
if (obj[sfLedgerEntryType.jsonName] == jss::NFTokenPage &&
obj.isMember(sfSponsor.jsonName))
foundNFTPage = true;
}
BEAST_EXPECT(foundNFTPage == sponsored);
BEAST_EXPECT(objs.size() == 0);
}
{
auto const resp = acctObjsSponsored(env, sponsee.id(), true, jss::sponsorship);
auto const& objs = resp[jss::result][jss::account_objects];
BEAST_EXPECT(objs.size() == 0);
}
}
}