Sponsored Fees & Reserves Guide (XLS-68)

This guide explains how to use XRPL Sponsored Fees & Reserves with the XrplCSharp SDK. Sponsorship lets one account (the sponsor) pay transaction fees and/or object reserves on behalf of another account (the sponsee) — the building block for gasless onboarding and custodial UX.

Note: Requires the Sponsor amendment (XLS-68). As of mid-2026 it exists only on the rippled develop branch — it is not part of any release and is not active on mainnet or testnet. Use the nightly Docker stand from this repository to try it (see Testing). The feature is in draft and subject to change.

Table of Contents

  • Overview
  • Key Concepts
  • Transaction Types
  • Step-by-Step: Establishing a Sponsorship
  • Sending a Sponsored Transaction
  • Signing Flows (V1/V2/V3)
  • Sponsorship inside a Batch
  • Fees
  • Ledger Objects
  • Testing
  • Common Errors

Overview

Sponsor (rSponsor...)                    Sponsee (rUser...)
┌───────────────────────┐               ┌───────────────────────┐
│ SponsorshipSet        │──────────────►│ may now send txs with │
│   Sponsee: rUser...   │               │   Sponsor: rSponsor...│
│   FeeAmount: 5 XRP    │               │   SponsorFlags: fee   │
│   RemainingOwnerCount │               │ fee is charged to the │
└───────────────────────┘               │ sponsor, not the user │
        creates                         └───────────────────────┘
┌───────────────────────┐
│ Sponsorship (ledger)  │
│   Owner / Sponsee     │
│   FeeAmount budget    │
│   RemainingOwnerCount │
└───────────────────────┘

Two independent dimensions can be sponsored:

Dimension SponsorFlags value What the sponsor pays
Fees SponsorCoverage.spfSponsorFee (= 1) The Fee of the sponsee's transactions
Reserves SponsorCoverage.spfSponsorReserve (= 2) Reserves on behalf of the sponsee: owner reserves of objects it creates and account reserves (including sponsored account creation)

Every transaction type gains three common fields: Sponsor, SponsorFlags, and (when the sponsorship demands a co-signature) SponsorSignature — an inner not-signing STObject over the same preimage as the main signature. It comes in two alternative forms: single-signature (SigningPubKey + TxnSignature) or sponsor multisig (a nested Signers array).


Key Concepts

The Sponsorship ledger object

Created by SponsorshipSet, one per sponsor/sponsee pair:

Field Meaning
Owner The sponsoring account
Sponsee The sponsored account
FeeAmount Remaining XRP budget for sponsored fees
RemainingOwnerCount How many more objects the sponsor will cover reserves for

Require-signature mode

By default a sponsee can spend the sponsorship budget without the sponsor's participation. SponsorshipSet flags flip that per dimension:

  • tfSponsorshipSetRequireSignForFee / tfSponsorshipClearRequireSignForFee
  • tfSponsorshipSetRequireSignForReserve / tfSponsorshipClearRequireSignForReserve

When required, the sponsee's transaction must carry a valid SponsorSignature — see Signing Flows.

Accounting fields

  • AccountRoot: SponsoredOwnerCount, SponsoringOwnerCount, SponsoringAccountCount
  • RippleState: HighSponsor / LowSponsor — who covers the reserve of each trust-line side

Transaction Types

SponsorshipSet

Creates/updates the relationship or deletes it with tfDeleteObject. Per rippled SponsorshipSet::preflight, exactly one of Sponsee / CounterpartySponsor is present (the submitter is the other side); only the sponsor can create or update, but either side can delete:

// The sponsor creates/updates (names the sponsee):
var setup = new SponsorshipSet
{
    Account = sponsor.ClassicAddress,
    Sponsee = sponsee.ClassicAddress,
    FeeAmount = new Currency { ValueAsXrp = 5m },
    RemainingOwnerCount = 3,
};
setup = await client.Autofill(setup);
await client.SubmitAndWait(setup, sponsor, true);

// The sponsee deletes its own sponsorship (names the sponsor);
// deletion forbids the modification flags and FeeAmount/MaxFee/RemainingOwnerCount:
var deletion = new SponsorshipSet
{
    Account = sponsee.ClassicAddress,
    CounterpartySponsor = sponsor.ClassicAddress,
    Flags = SponsorshipSetFlags.tfDeleteObject,
};
deletion = await client.Autofill(deletion);
await client.SubmitAndWait(deletion, sponsee, true);

SponsorshipTransfer

Creates, moves or terminates reserve sponsorship of existing ledger objects. Exactly one mode flag is required (per rippled SponsorshipTransfer::preflight; the target owner is Account for Create/Reassign — Sponsee is forbidden there):

Flag Who sends What it does Extra fields
tfSponsorshipCreate the sponsee (owner of the unsponsored objects) establishes reserve sponsorship of existing objects Sponsor (the new sponsor) + spfSponsorReserve required; the sponsor co-signs
tfSponsorshipReassign the sponsee (owner of the sponsored objects) moves the reserve from the current sponsor to a new one Sponsor (the new sponsor) + spfSponsorReserve required; the new sponsor co-signs
tfSponsorshipEnd sponsee or sponsor removes reserve sponsorship (target = Sponsee if present, else Account) Sponsor and SponsorFlags forbidden

Step-by-Step: Establishing a Sponsorship

XrplWallet sponsor = XrplWallet.Generate();
XrplWallet sponsee = XrplWallet.Generate();
// fund both wallets first

var tx = new SponsorshipSet
{
    Account = sponsor.ClassicAddress,
    Sponsee = sponsee.ClassicAddress,
    FeeAmount = new Currency { ValueAsXrp = 5m },
    RemainingOwnerCount = 3,
};
tx = await client.Autofill(tx);
TransactionSummary result = await client.SubmitAndWait(tx, sponsor, true);
// result.Meta.TransactionResult == "tesSUCCESS"

Verify via account_objects:

var request = new AccountObjectsRequest(sponsor.ClassicAddress) { Type = LedgerEntryType.Sponsorship };
var objects = await client.AccountObjects(request);
LOSponsorship sponsorship = objects.AccountObjectList.OfType<LOSponsorship>().First();

Sending a Sponsored Transaction

The sponsee sends any ordinary transaction with the sponsorship fields:

var payment = new Payment
{
    Account = sponsee.ClassicAddress,
    Destination = destination.ClassicAddress,
    Amount = new Currency { ValueAsXrp = 1m },
    Sponsor = sponsor.ClassicAddress,
    SponsorFlags = SponsorCoverage.spfSponsorFee,
};
payment = await client.Autofill(payment);

If the sponsorship does not require a co-signature, sign and submit as usual. If it does, use one of the flows below.


Signing Flows (V1/V2/V3)

SponsorSignature is signed over the same preimage as the main signature (analogous to the LoanSet counterparty pattern).

The simple path — standard Sign/Submit (10.8.0+)

No helper choice is needed: the standard API routes by role. A wallet matching tx.Sponsor produces the sponsor co-signature; the submitter's wallet signs the main signature preserving an existing SponsorSignature; the smart SubmitAndWait composes, pre-checks the sponsorship's require-sign flags against the ledger and submits.

// Decodes a handed-over blob back into a signable transaction
static Dictionary<string, object> Reparse(string blob) =>
    JsonSerializer.Deserialize<Dictionary<string, object>>(
        XrplBinaryCodec.Decode(blob).ToJsonString(), XrplJsonOptions.Default);

// Both keys local — one call:
await client.SubmitAndWaitSponsored(payment, sponseeWallet, sponsorWallet);

// Keys on different devices — each side just calls Sign:
var sponsorPart = sponsorWallet.Sign(preparedTx);               // adds SponsorSignature
var final = sponseeWallet.Sign(Reparse(sponsorPart.TxBlob));    // adds the main signature
await client.SubmitRequest(final.TxBlob, true);

// Or let the submitting side finish everything:
await client.SubmitAndWait(partiallySignedTx, sponsorWallet); // sponsor finalizes a sponsee-signed tx

If a required signature is missing, SubmitAndWait fails fast with "transaction is not signed by all participants" instead of a node-side error. Multisig on either side stays portable: devices sign with multisign: true and client.ComposeSignatures(parts) routes the Signer entries into the right section by the ledger SignerLists (with a quorum-by-weight pre-check).

Advanced — explicit helper flows

V1 — Automatic (both keys available in one process):

JsonObject prepared = SponsorSigningHelper.PrepareForSigning(payment, sponseeWallet);
SignatureResult signed = SponsorSigningHelper.SignSponsored(prepared, sponseeWallet, sponsorWallet);
await client.SubmitRequest(signed.TxBlob, true);

V2 — Parallel (keys on separate devices, combine later):

var sponsorSig  = sponsorWallet.SignAsSponsor(prepared);
var submitterSig = sponseeWallet.Sign(prepared);
var combined = SponsorSigningHelper.CombineSponsorSignatures(submitterSig.TxBlob, sponsorSig.TxBlob);
await client.SubmitRequest(combined.TxBlob, true);

V3 — Sequential (sponsor first, then submitter finalizes):

var withSponsor = sponsorWallet.SignAsSponsor(prepared);
var final = SponsorSigningHelper.SubmitterSign(withSponsor.TxBlob, sponseeWallet);
await client.SubmitRequest(final.TxBlob, true);

Sponsorship inside a Batch

Sponsorship composes with Batch (XLS-56), with rules mirrored from rippled Batch::preflight:

  • Reserve-sponsored inner transaction: set Sponsor + spfSponsorReserve on the inner and add an empty SponsorSignature object as a marker. The marker makes the sponsor a required batch signer — the sponsor then authorizes the whole batch through the standard Sign, either single-signed or through its SignerList (a nested-multisig BatchSigner.Signers entry). No signature material ever goes inside the inner marker.
  • Fee-sponsored outer batch: Sponsor + spfSponsorFee on the outer Batch; the sponsor co-signs the batch itself with a regular SponsorSignature via the standard Sign.
  • Forbidden by protocol (ValidateBatch rejects these client-side): spfSponsorReserve on the outer Batch, fee sponsorship on inner transactions, signature material inside inner co-signature markers, and any Loan/Vault transaction as an inner (rippled kDisabledTxTypes → temINVALID_INNER_BATCH) — so LoanSet counterparty co-signing cannot ride inside a Batch.
// Inner reserve-sponsored TrustSet inside a Batch; the sponsor signs via its
// SignerList (Reparse — see "The simple path" above)
SignatureResult holderPart  = holder.Sign(batchDict);
SignatureResult signer1Part = signer1.Sign(Reparse(holderPart.TxBlob),  multisign: true, signingFor: sponsor.ClassicAddress);
SignatureResult signer2Part = signer2.Sign(Reparse(signer1Part.TxBlob), multisign: true, signingFor: sponsor.ClassicAddress);
SignatureResult final       = root.Sign(Reparse(signer2Part.TxBlob));

Fees

Per rippled Transactor::calculateBaseFee, a single-signed SponsorSignature adds nothing to the base fee. Only when the sponsor co-signs with multisig does each signer nested in SponsorSignature.Signers add one base fee unit. The SDK's Autofill accounts for this automatically.


Ledger Objects

  • LOSponsorship — the relationship object (see Key Concepts)
  • LOAccountRoot.SponsoredOwnerCount / SponsoringOwnerCount / SponsoringAccountCount
  • LORippleState.HighSponsor / LowSponsor

Testing

The amendment is develop-only. The repository ships a nightly stand with Sponsor enabled at genesis:

docker compose -f .ci-config/docker-compose.batchv11.yml up -d --build
dotnet test Tests/Xrpl.Tests/Xrpl.Tests.csproj --settings test.runsettings --filter "TestISponsorship"

Integration tests are gated by AmendmentGuard: on a node without the amendment they exit as inconclusive instead of failing. See Tests/Xrpl.Tests/Integration/transactions/TestISponsorship.cs for complete working examples, and the Standalone Node Guide for stand details.


Common Errors

Error Meaning
temDISABLED The Sponsor amendment is not active on the node
tecNO_SPONSOR_PERMISSION The sponsorship does not cover this dimension, is out of budget, or the required SponsorSignature is missing/invalid
terNO_PERMISSION Relationship state does not allow the operation (e.g. transferring someone else's sponsorship)
Validation: "invalid Sponsor" The Sponsor field is not a string address (client-side check)

Русская версия: Sponsorship-Guide.ru

  • Edit this page
In this article
Back to top Generated by DocFX