Stellar sponsored account and trustline

This guide shows how to create a sponsored account and open a token trustline on Stellar using the Utila API, by building the transaction envelope with a Stellar SDK and submitting it through the stellarRawTransaction path.

What we're building

The goal - a payments use case: a sponsor wallet pays the reserves to create a new account and open its USDC trustline, so the new account can receive USDC immediately without ever holding XLM.

On-chain this is the standard Stellar sponsored reserves sandwich - four operations in one transaction:

#OperationSource accountWho signs
1beginSponsoringFutureReserves(sponsoredId = ACCOUNT)Sponsor (tx source)Sponsor
2createAccount(destination = ACCOUNT, startingBalance = 0)Sponsor (tx source)Sponsor
3changeTrust(USDC, limit = max)ACCOUNTSponsored account
4endSponsoringFutureReservesACCOUNTSponsored account

Example identities used throughout:

  • Sponsor: GCVUNHNTB7K2E7GJ36UXD3MWRDUY6LMFVS4KJ6LRVLZLNKCPSJKA6Z46
  • Sponsored account: GDY4K544LPVIRLKRWUC57OURX3TFRIPB5YP6AZ2CT7CGF736AGYLGI4G
  • USDC issuer: GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN

Prerequisites

Both wallets must exist in Utila before you submit - even if the sponsored account doesn't yet exist on-chain. Utila resolves signers from the XDR at submission time. If the sponsored wallet hasn't been created in the vault, initiation may succeed but signing will fail later with incomplete signatures.

RequirementDetails
Sponsor wallet — Utila-managed, same vaultMust already exist on-chain - Utila calls Horizon to fetch the sponsor's current sequence number at signing time
Sponsored wallet - Utila-managed, same vaultMust be created in Utila before submitting, even though the account doesn't exist on-chain yet. The Stellar address is derived from the wallet's key and is known before the account exists.
Sponsor has sufficient XLMNeeds XLM for: (1) transaction fee (4 × base fee), and (2) the account reserve and trustline reserve that the sponsor locks via sponsorship. Check your sponsor balance before submitting.
Utila API token with initiator roleSIGNER, NON_SIGNING_OPERATOR, NON_SIGNING_ADMIN, or ADMIN
A Stellar SDKTo build the transaction envelope - examples below in JavaScript and Python
Vault ID and network resource namee.g. vaults/1b25635a5b3f and networks/stellar-mainnet or networks/stellar-testnet

How the raw path works

1. Build with empty signatures. Utila resolves the required signers from the XDR - the tx source (sponsor) plus every per-op source (sponsored account) - and signs with MPC for both. You do not call .sign().

2. Sequence number is a placeholder. useLatestSequenceNumber defaults to true, so Utila fetches the sponsor's current on-chain sequence right before signing. Build with sequence 0.

3. xdrEnvelope is base64. Over REST/JSON, bytes fields are base64-encoded. Every Stellar SDK's toXDR() / to_xdr() already returns a base64 string - pass it straight through.

Step 1 - Build the transaction envelope

Build the four-operation transaction with the sponsor as the transaction source. Set operations 3 and 4's source to the sponsored account. Leave operations 1 and 2 without an explicit source so they inherit the sponsor. Do not sign. Export base64 XDR.

JavaScript (stellar-sdk)

const {
  Account,
  Asset,
  BASE_FEE,
  Networks,
  Operation,
  TransactionBuilder,
} = require("stellar-sdk");

const SPONSOR = "GCVUNHNTB7K2E7GJ36UXD3MWRDUY6LMFVS4KJ6LRVLZLNKCPSJKA6Z46";
const ACCOUNT = "GDY4K544LPVIRLKRWUC57OURX3TFRIPB5YP6AZ2CT7CGF736AGYLGI4G";
const USDC = new Asset(
  "USDC",
  "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
);

// Sequence "0" is a placeholder — Utila overrides it before signing.
const source = new Account(SPONSOR, "0");

const tx = new TransactionBuilder(source, {
  fee: BASE_FEE, // total fee = BASE_FEE × number of operations (4 here)
  networkPassphrase: Networks.PUBLIC, // use Networks.TESTNET for stellar-testnet
})
  .addOperation(Operation.beginSponsoringFutureReserves({ sponsoredId: ACCOUNT }))
  .addOperation(Operation.createAccount({ destination: ACCOUNT, startingBalance: "0" }))
  // Omit `limit` for an unlimited trustline; pass e.g. limit: "1000.50" to cap it.
  .addOperation(Operation.changeTrust({ source: ACCOUNT, asset: USDC }))
  .addOperation(Operation.endSponsoringFutureReserves({ source: ACCOUNT }))
  .setTimeout(0) // 0 = no upper timebound; use a value (e.g. 300) to set a max time
  .build();

// DO NOT call tx.sign(...)
console.log(tx.toXDR()); // base64 string — goes straight into xdrEnvelope

Python (stellar-sdk)

from stellar_sdk import Account, Asset, Network, TransactionBuilder

SPONSOR = "GCVUNHNTB7K2E7GJ36UXD3MWRDUY6LMFVS4KJ6LRVLZLNKCPSJKA6Z46"
ACCOUNT = "GDY4K544LPVIRLKRWUC57OURX3TFRIPB5YP6AZ2CT7CGF736AGYLGI4G"
USDC = Asset("USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN")

# Sequence 0 is a placeholder — Utila overrides it before signing.
source = Account(SPONSOR, 0)

tx = (
    TransactionBuilder(
        source_account=source,
        network_passphrase=Network.PUBLIC_NETWORK_PASSPHRASE,
        # Use Network.TESTNET_NETWORK_PASSPHRASE for stellar-testnet
        base_fee=100,  # total fee = base_fee x number of operations (4 here)
    )
    .append_begin_sponsoring_future_reserves_op(sponsored_id=ACCOUNT)
    .append_create_account_op(destination=ACCOUNT, starting_balance="0")
    # Omit `limit` for unlimited; pass limit="1000.50" to cap it.
    .append_change_trust_op(asset=USDC, source=ACCOUNT)
    .append_end_sponsoring_future_reserves_op(source=ACCOUNT)
    # No timebounds.
    # Do NOT use .set_timeout(0) — unlike JS (where 0 = TimeoutInfinite),
    # Python's set_timeout(N) sets max_time = now + N seconds.
    # set_timeout(0) makes the envelope expire at build time.
    # Omit set_timeout() entirely for no timebound.
    .build()
)

# DO NOT call tx.sign(...)
print(tx.to_xdr())  # base64 string — goes straight into xdrEnvelope

Both produce a base64 string like AAAAAg... - the unsigned transaction envelope.

Step 2 - Submit via InitiateTransaction

POST /v2/vaults/{vault_id}/transactions:initiate

The vault ID goes in the URL path. Put the base64 XDR from Step 1 in details.stellarRawTransaction.xdrEnvelope.

curl -X POST \
  "https://api.utila.io/v2/vaults/1b25635a5b3f/transactions:initiate" \
  -H "Authorization: Bearer $UTILA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "details": {
      "stellarRawTransaction": {
        "network": "networks/stellar-mainnet",
        "sourceAddress": "GCVUNHNTB7K2E7GJ36UXD3MWRDUY6LMFVS4KJ6LRVLZLNKCPSJKA6Z46",
        "xdrEnvelope": "AAAAAg...REPLACE_WITH_BASE64_XDR_FROM_STEP_1...",
        "publish": true,
        "useLatestSequenceNumber": true
      }
    },
    "note": "Sponsor USDC account + trustline for GDY4...",
    "externalId": "trustline-gdy4-usdc-001"
  }'

Field reference:

FieldRequiredNotes
networkYesMust match the network you built for in Step 1
sourceAddressYesThe sponsor's Stellar address - must equal the XDR transaction source. A mismatch does not fail at submission; it fails later at signing with a confusing error.
xdrEnvelopeYesBase64 XDR from Step 1
publishNotrue (default) to broadcast after signing. Set false to stop at SIGNED and broadcast separately.
useLatestSequenceNumberNoLeave true (default). Only set false if you deliberately pinned a real sequence number in Step 1.
noteNoHuman-readable context, visible in the console
externalIdNoYour system's reference ID - use for idempotent correlation

The response is a Transaction resource. Capture its name (vaults/{vault}/transactions/{id}) to track status.

Step 3 - Approvals and signing

After submission:

  1. Utila identifies the required signers from the XDR: the tx source (sponsor) and each per-op source (sponsored account). Both must be wallets in your vault.
  2. The transaction enters your vault's policy/approval flow, moving through AWAITING_POLICY_CHECK and optionally AWAITING_APPROVAL.
  3. Once approved, Utila runs two MPC signing sessions (one per signer), sets the live sequence number on the sponsor's account, and broadcasts if publish: true.

Step 4 - Verify

Poll transaction status:

GET /v2/vaults/{vault_id}/transactions/{transaction_id}

Watch for state to reach MINED or CONFIRMED. The full state flow:

AWAITING_POLICY_CHECK → AWAITING_SIGNATURE → SIGNED → AWAITING_PUBLISH → PUBLISHED → MINED → CONFIRMED

Terminal failure states: MINED_FAILED (on-chain failure - investigate before retrying), FAILED, DECLINED (rejected by policy).

Verify on-chain:

Once MINED, capture the hash field from the transaction resource and look it up on Stellar Expert or Horizon. Confirm:

  • All four operations succeeded
  • The new account shows a USDC trustline
  • The trustline's reserve sponsor is the sponsor address (GCVU…)

Common variations

Trustline only (account already exists): drop createAccount (op 2). Keep beginSponsoringFutureReserveschangeTrustendSponsoringFutureReserves.

Cap the trustline limit: pass limit: "1000.50" (JS) or limit="1000.50" (Python) on changeTrust.

Multiple trustlines under one sponsorship: one beginSponsoringFutureReserves, multiple changeTrust operations (each with source = ACCOUNT), one endSponsoringFutureReserves.

Different token: change Asset(code, issuer). For testnet, use the testnet issuer and networks/stellar-testnet plus the testnet passphrase.

Gotchas / FAQs

sourceAddress must match the XDR transaction source. Utila uses the XDR source for signer resolution. A mismatch doesn't fail at submission - it fails at signing with an incomplete or confusing error.

Create both wallets in Utila before submitting. If the sponsored wallet doesn't exist in the vault, the transaction may be accepted at initiation but fail at signing. The sponsored account does not need to exist on-chain - only in Utila.

Sponsor must already exist on-chain. Utila fetches the sponsor's current sequence number from Horizon at sign time. If the sponsor account doesn't exist on-chain yet, this fetch fails.

Sponsor needs XLM for reserves, not just fees. The fee is 4 × base fee (400 stroops at default). The sponsor also locks base reserves for the sponsored account and the trustline entry - check the sponsor's XLM balance before submitting.

Python set_timeout(0) expires immediately. Unlike JS where setTimeout(0) means no timebound, Python's set_timeout(N) sets max_time = now + N seconds. Omit set_timeout() entirely for no timebound.

Account already exists / trustline already exists: createAccount against an existing account fails the whole transaction - use the trustline-only variation. Re-running changeTrust for an existing trustline is a no-op but still costs a fee.

Sequence number mismatches: handled automatically when useLatestSequenceNumber: true. If you set it to false, you must build with the sponsor's exact current sequence or broadcast will fail with tx_bad_seq.

Timebounds: setTimeout(0) means no upper time bound, which is safest while the transaction waits for approvals. If you set a finite timeout, make it long enough to cover your approval flow.

When to use the structured API instead

The stellarRawTransaction path (this guide) is required when you need reserve sponsorship. The structured stellarTransaction path does not support beginSponsoringFutureReserves / endSponsoringFutureReserves today.

ScenarioBetter path
New account can hold XLM for its own reservesstellarTransaction with createAccount + changeTrust - no SDK required
Account already exists and has XLMSingle changeTrust via stellarTransaction
Reserve sponsorship - zero XLM on the new account (this guide)stellarRawTransaction + SDK-built XDR

Related

  • Stellar transactions - full reference for the stellarTransaction structured path
  • Transaction fundamentals - Lifecycle & states - full state machine reference
  • Stellar Expert - on-chain explorer for verifying transactions
  • Horizon API docs