Merge branch 'xrplf/sponsor' into mvadari/sponsor/apply-view-context

This commit is contained in:
Mayukha Vadari
2026-06-29 14:20:22 -04:00
committed by GitHub
4 changed files with 256 additions and 56 deletions

View File

@@ -1398,17 +1398,17 @@ public:
env.close();
// Helper to call account_objects with sponsored filter
auto acctObjsSponsored = [&env](
AccountID const& acct,
bool sponsored,
std::optional<json::StaticString> const& type = std::nullopt) {
auto acctObjsSponsored = [](Env& testEnv,
AccountID const& acct,
bool sponsored,
std::optional<json::StaticString> const& type = std::nullopt) {
json::Value params;
params[jss::account] = to_string(acct);
params[jss::sponsored] = sponsored;
if (type)
params[jss::type] = *type;
params[jss::ledger_index] = "validated";
return env.rpc("json", "account_objects", to_string(params));
return testEnv.rpc("json", "account_objects", to_string(params));
};
// Create a sponsorship (alice sponsors bob)
@@ -1421,14 +1421,15 @@ public:
// sponsored=true should not find any objects for bob (doesn't have any sponsored objects)
{
auto const resp = acctObjsSponsored(bob.id(), true);
auto const resp = acctObjsSponsored(env, bob.id(), true);
auto const& objs = resp[jss::result][jss::account_objects];
BEAST_EXPECT(objs.size() == 0);
}
// Now sponsor bob's trust line
auto const trustId = keylet::line(bob, gw, usd.currency);
BEAST_EXPECT(env.le(trustId));
if (!BEAST_EXPECT(env.le(trustId)))
return;
env(sponsor::transfer(bob, tfSponsorshipCreate, trustId.key),
sponsor::As(sponsor1, spfSponsorReserve),
@@ -1438,31 +1439,27 @@ public:
// Verify trust line has sponsor field
{
auto const sle = env.le(trustId);
if (!BEAST_EXPECT(sle))
return;
BEAST_EXPECT(sle->isFieldPresent(sfHighSponsor) || sle->isFieldPresent(sfLowSponsor));
}
// sponsored=true on bob should include the sponsored trust line
{
auto const resp = acctObjsSponsored(bob.id(), true);
auto const resp = acctObjsSponsored(env, bob.id(), true);
auto const& objs = resp[jss::result][jss::account_objects];
bool foundTrustLine = false;
BEAST_EXPECT(objs.size() == 1);
for (auto const& obj : objs)
{
if (obj[sfLedgerEntryType.jsonName] == jss::RippleState)
{
BEAST_EXPECT(
obj.isMember(sfHighSponsor.jsonName) ||
obj.isMember(sfLowSponsor.jsonName));
foundTrustLine = true;
}
}
BEAST_EXPECT(foundTrustLine);
if (!BEAST_EXPECT(objs.size() == 1))
return;
auto const& obj = objs[0u];
BEAST_EXPECT(obj[sfLedgerEntryType.jsonName] == jss::RippleState);
BEAST_EXPECT(
obj.isMember(sfHighSponsor.jsonName) || obj.isMember(sfLowSponsor.jsonName));
}
// sponsored=false on bob should NOT include the sponsored trust line
{
auto const resp = acctObjsSponsored(bob.id(), false);
auto const resp = acctObjsSponsored(env, bob.id(), false);
auto const& objs = resp[jss::result][jss::account_objects];
bool foundSponsoredTrustLine = false;
for (auto const& obj : objs)
@@ -1476,6 +1473,112 @@ public:
BEAST_EXPECT(!foundSponsoredTrustLine);
}
// Only the queried side of a shared trust line should determine
// sponsorship classification.
{
Env env(*this, testableAmendments());
Account const issuer("issuer");
Account const user("user");
Account const sponsor("sponsor");
auto const usd = issuer["USD"];
env.fund(XRP(10000), issuer, user, sponsor);
env.close();
env(trust(issuer, user["USD"](100)));
env.close();
env(trust(user, usd(100)));
env.close();
auto const trustId = keylet::line(user, issuer, usd.currency);
if (!BEAST_EXPECT(env.le(trustId)))
return;
env(sponsor::transfer(user, tfSponsorshipCreate, trustId.key),
sponsor::As(sponsor, spfSponsorReserve),
Sig(sfSponsorSignature, sponsor));
env.close();
auto const line = env.le(trustId);
if (!BEAST_EXPECT(line))
return;
auto const userIsHigh = line->getFieldAmount(sfHighLimit).getIssuer() == user.id();
auto const& userSponsorField = userIsHigh ? sfHighSponsor : sfLowSponsor;
auto const& issuerSponsorField = userIsHigh ? sfLowSponsor : sfHighSponsor;
BEAST_EXPECT(line->isFieldPresent(userSponsorField));
BEAST_EXPECT(!line->isFieldPresent(issuerSponsorField));
{
auto const resp = acctObjsSponsored(env, user.id(), true, jss::state);
auto const& objs = resp[jss::result][jss::account_objects];
if (BEAST_EXPECT(objs.size() == 1))
BEAST_EXPECT(objs[0u][sfLedgerEntryType.jsonName] == jss::RippleState);
}
{
auto const resp = acctObjsSponsored(env, user.id(), false, jss::state);
auto const& objs = resp[jss::result][jss::account_objects];
BEAST_EXPECT(objs.size() == 0);
}
{
auto const resp = acctObjsSponsored(env, issuer.id(), true, jss::state);
auto const& objs = resp[jss::result][jss::account_objects];
BEAST_EXPECT(objs.size() == 0);
}
{
auto const resp = acctObjsSponsored(env, issuer.id(), false, jss::state);
auto const& objs = resp[jss::result][jss::account_objects];
if (BEAST_EXPECT(objs.size() == 1))
BEAST_EXPECT(objs[0u][sfLedgerEntryType.jsonName] == jss::RippleState);
}
}
// A Sponsorship object is visible to both sides, but its reserve side
// belongs only to sfOwner.
{
Env env(*this, testableAmendments());
Account const owner("owner");
Account const sponsee("sponsee");
Account const sponsor("sponsor");
env.fund(XRP(10000), owner, sponsee, sponsor);
env.close();
env(sponsor::set_reserve(sponsor, 0, 100), sponsor::SponseeAcc(owner));
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))
return;
BEAST_EXPECT(sponsorship->isFieldPresent(sfSponsor));
{
auto const resp = acctObjsSponsored(env, owner.id(), true, 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)
@@ -1483,7 +1586,8 @@ public:
env.close();
auto const nftPageKeylet = keylet::nftpageMax(bob);
BEAST_EXPECT(env.le(nftPageKeylet));
if (!BEAST_EXPECT(env.le(nftPageKeylet)))
return;
// Sponsor the NFT page
env(sponsor::transfer(bob, tfSponsorshipCreate, nftPageKeylet.key),
@@ -1492,13 +1596,16 @@ public:
env.close();
// Verify NFT page has sponsor field
BEAST_EXPECT(env.le(nftPageKeylet)->isFieldPresent(sfSponsor));
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(bob.id(), sponsored);
auto const resp = acctObjsSponsored(env, bob.id(), sponsored);
auto const& objs = resp[jss::result][jss::account_objects];
bool foundNFTPage = false;
for (auto const& obj : objs)

View File

@@ -364,6 +364,20 @@ class Simulate_test : public beast::unit_test::Suite
auto const resp = env.rpc("json", "simulate", to_string(params));
BEAST_EXPECT(resp[jss::result][jss::error_message] == "Invalid field 'tx.Signers[0]'.");
}
{
// Non-object SponsorSignature field
json::Value params;
json::Value txJson = json::ValueType::Object;
txJson[jss::TransactionType] = jss::AccountSet;
txJson[jss::Account] = env.master.human();
txJson[sfSponsorSignature] = "";
params[jss::tx_json] = txJson;
auto const resp = env.rpc("json", "simulate", to_string(params));
BEAST_EXPECT(
resp[jss::result][jss::error_message] ==
"Invalid field 'SponsorSignature', not object.");
}
{
// Invalid transaction
json::Value params;
@@ -817,6 +831,58 @@ class Simulate_test : public beast::unit_test::Suite
}
}
void
testSuccessfulSponsoredTransactionMultisigned()
{
testcase("Successful sponsored multi-signed transaction");
using namespace jtx;
Env env(*this);
Account const sponsor("sponsor");
Account const signer("signer");
env.fund(XRP(10000), sponsor, signer);
env.close();
env(signers(sponsor, 1, {{signer, 1}}));
env.close();
auto validateOutput = [&](json::Value const& resp, json::Value const& tx) {
auto const result = resp[jss::result];
// Verifies Fee autofill counts nested sponsor-signature signers.
auto const expectedFee = env.current()->fees().base * 2;
checkBasicReturnValidity(result, tx, env.seq(env.master), expectedFee);
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.");
if (BEAST_EXPECT(result.isMember(jss::meta) || result.isMember(jss::meta_blob)))
{
json::Value const metadata = getJsonMetadata(result);
BEAST_EXPECT(metadata[sfTransactionResult.jsonName] == "tesSUCCESS");
}
};
json::Value tx;
tx[jss::Account] = env.master.human();
tx[jss::TransactionType] = jss::AccountSet;
tx[sfDomain] = "123ABC";
tx[sfSponsor.jsonName] = sponsor.human();
tx[sfSponsorFlags.jsonName] = spfSponsorFee;
tx[sfSponsorSignature.jsonName] = json::ValueType::Object;
tx[sfSponsorSignature.jsonName][sfSigners.jsonName] = json::ValueType::Array;
json::Value signerObj;
signerObj[sfSigner][jss::Account] = signer.human();
tx[sfSponsorSignature.jsonName][sfSigners.jsonName].append(signerObj);
// Leave Fee unset so simulate must autofill it after sponsor signer normalization.
BEAST_EXPECT(!tx.isMember(jss::Fee));
testTx(env, tx, validateOutput, false);
}
void
testTransactionSigningFailure()
{
@@ -1249,6 +1315,7 @@ public:
testTransactionNonTecFailure();
testTransactionTecFailure();
testSuccessfulTransactionMultisigned();
testSuccessfulSponsoredTransactionMultisigned();
testTransactionSigningFailure();
testInvalidSingleAndMultiSigningTransaction();
testMultisignedBadPubKey();

View File

@@ -33,7 +33,8 @@ 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 sponsored Whether to filter by sponsored objects.
@param hasSponsoredFilter Whether to filter by sponsored objects.
@param sponsored Whether filtered objects should be sponsored.
@param jvResult A JSON result that holds the request objects.
*/
bool
@@ -44,7 +45,8 @@ getAccountObjects(
uint256 dirIndex,
uint256 entryIndex,
std::uint32_t const limit,
std::optional<bool> const sponsored,
bool const hasSponsoredFilter,
bool const sponsored,
json::Value& jvResult)
{
// check if dirIndex is valid
@@ -103,12 +105,12 @@ getAccountObjects(
while (currentPage)
{
bool canAppendNFT = true;
if (sponsored.has_value())
if (hasSponsoredFilter)
{
std::optional<AccountID> const nftSponsor = currentPage->isFieldPresent(sfSponsor)
? currentPage->getAccountID(sfSponsor)
: std::optional<AccountID>(std::nullopt);
if (!sponsoredMatchesFilter(sponsored.value(), nftSponsor))
if (!sponsoredMatchesFilter(sponsored, nftSponsor))
canAppendNFT = false;
}
if (canAppendNFT)
@@ -204,21 +206,33 @@ getAccountObjects(
!typeMatchesFilter(typeFilter.value(), sleNode->getType()))
canAppend = false;
auto const getSponsor = [&sleNode]() -> std::optional<AccountID> {
if (sleNode->isFieldPresent(sfSponsor))
return sleNode->getAccountID(sfSponsor);
auto const getSponsor = [&account, &sleNode]() -> std::optional<AccountID> {
if (sleNode->getType() == ltRIPPLE_STATE)
{
if (sleNode->isFieldPresent(sfHighSponsor))
if (sleNode->isFlag(lsfHighReserve) &&
sleNode->getFieldAmount(sfHighLimit).getIssuer() == account &&
sleNode->isFieldPresent(sfHighSponsor))
return sleNode->getAccountID(sfHighSponsor);
if (sleNode->isFieldPresent(sfLowSponsor))
if (sleNode->isFlag(lsfLowReserve) &&
sleNode->getFieldAmount(sfLowLimit).getIssuer() == account &&
sleNode->isFieldPresent(sfLowSponsor))
return sleNode->getAccountID(sfLowSponsor);
return std::nullopt;
}
if (sleNode->getType() == ltSPONSORSHIP &&
sleNode->getAccountID(sfOwner) != account)
return std::nullopt;
if (sleNode->isFieldPresent(sfSponsor))
return sleNode->getAccountID(sfSponsor);
return std::nullopt;
};
std::optional<AccountID> const sponsor = getSponsor();
if (sponsored.has_value() && !sponsoredMatchesFilter(sponsored.value(), sponsor))
if (hasSponsoredFilter && !sponsoredMatchesFilter(sponsored, sponsor))
canAppend = false;
if (canAppend)
@@ -369,8 +383,9 @@ doAccountObjects(RPC::JsonContext& context)
return RPC::invalidFieldError(jss::marker);
}
std::optional<bool> sponsored;
if (params.isMember(jss::sponsored))
bool const hasSponsoredFilter = params.isMember(jss::sponsored);
bool sponsored = false;
if (hasSponsoredFilter)
{
auto const& sponsoredJv = params[jss::sponsored];
if (!sponsoredJv.isBool())
@@ -380,7 +395,15 @@ doAccountObjects(RPC::JsonContext& context)
}
if (!getAccountObjects(
*ledger, accountID, typeFilter, dirIndex, entryIndex, limit, sponsored, result))
*ledger,
accountID,
typeFilter,
dirIndex,
entryIndex,
limit,
hasSponsoredFilter,
sponsored,
result))
return RPC::invalidFieldError(jss::marker);
result[jss::account] = toBase58(accountID);

View File

@@ -131,29 +131,16 @@ autofillSignature(json::Value& sigObject)
static std::optional<json::Value>
autofillTx(json::Value& txJson, RPC::JsonContext& context)
{
if (!txJson.isMember(jss::Fee))
{
// autofill Fee
// Must happen after all the other autofills happen
// Error handling/messaging works better that way
auto feeOrError = RPC::getCurrentNetworkFee(
context.role,
context.app.config(),
context.app.getFeeTrack(),
context.app.getTxQ(),
context.app,
txJson);
if (feeOrError.isMember(jss::error))
return feeOrError;
txJson[jss::Fee] = feeOrError;
}
if (auto error = autofillSignature(txJson))
return error;
if (txJson.isMember(sfSponsorSignature.jsonName))
{
if (auto error = autofillSignature(txJson[sfSponsorSignature.jsonName]))
auto& sponsorSignature = txJson[sfSponsorSignature.jsonName];
if (!sponsorSignature.isObject())
return RPC::objectFieldError(sfSponsorSignature.jsonName);
if (auto const error = autofillSignature(sponsorSignature))
return error;
}
@@ -172,6 +159,22 @@ autofillTx(json::Value& txJson, RPC::JsonContext& context)
txJson[jss::NetworkID] = to_string(networkId);
}
if (!txJson.isMember(jss::Fee))
{
// Autofill Fee after normalizing nested signer fields so the fee
// estimator sees the full transaction shape.
auto feeOrError = RPC::getCurrentNetworkFee(
context.role,
context.app.config(),
context.app.getFeeTrack(),
context.app.getTxQ(),
context.app,
txJson);
if (feeOrError.isMember(jss::error))
return feeOrError;
txJson[jss::Fee] = feeOrError;
}
return std::nullopt;
}