This guide shows how to send a Stellar classic asset (e.g. USDC) from one Utila wallet to another address, with a different wallet paying the network fee. The sending wallet doesn't need to hold any XLM; only the sponsor does.
What we're building
A common pattern on Stellar: an account holds a stablecoin but no native XLM, and needs to transfer that stablecoin without ever holding XLM to pay a fee. This is possible because Stellar transactions distinguish between the transaction source account (pays the fee and supplies the sequence number) and the operation source account (authorizes the actual asset movement). They can be different accounts.
On-chain this is a single-operation Stellar transaction:
| Level | Operation | Source account | Who signs |
|---|---|---|---|
| Transaction | Pays the fee, supplies the sequence number | Sponsor | Sponsor |
| Operation (1) | payment(destination, asset, amount) | Sender | Sender |
Both signatures are required: the sender to authorize the movement of its own asset, the sponsor to authorize paying the fee. Utila handles both via MPC in a single API call.
Roles used throughout:
- Sponsor: Utila-managed Stellar address that holds XLM and pays the fee
- Sender: Utila-managed Stellar address that holds the asset (zero XLM is fine)
- Destination: on-chain Stellar address that can receive the asset (classic and SAC→wallet paths still need a trustline; pure Soroban contract tokens do not)
- Asset: classic
{CODE}-{ISSUER}or SEP-41contractAddress(SAC for classic assets, or a pure Soroban contract token)
When to use this guide
Use this structured stellarTransaction path when the destination address already exists on-chain and has a trustline for the asset you're sending.
If the destination is a brand-new Stellar account that has never held XLM or the asset's trustline, use Stellar sponsored account and trustline first. That one sponsors account creation and trustline establishment in a single 4-operation transaction. Once that's done (once per destination), every subsequent payment can use the simpler flow described here.
| Scenario | Path |
|---|---|
| Destination is a new account with no XLM and no trustline | Sponsored account and trustline (raw XDR via stellarRawTransaction) |
| Destination already exists on-chain and has the trustline | This guide (structured stellarTransaction, JSON only) |
Getting started? Use the sponsored account and trustline flow for everything; it works whether or not the destination is set up. Once your integration is stable, switching to this simpler path for repeat payments saves fees and removes the SDK dependency.
Prerequisites
| Requirement | Details |
|---|---|
| Sender wallet (Utila-managed, same vault) | Holds a balance of the classic asset being sent. Zero XLM is fine. Must already have the trustline established. |
| Sponsor wallet (Utila-managed, same vault) | Holds enough XLM to cover its own minimum balance plus the transaction fee |
| Destination address | Must already exist on-chain and have an established trustline for the asset |
| Utila API token with initiator role | SIGNER, NON_SIGNING_OPERATOR, NON_SIGNING_ADMIN, or ADMIN |
| Vault ID and network resource name | e.g. vaults/{vault_id} and networks/stellar-mainnet or networks/stellar-testnet |
No SDK is required for this path. Utila builds and signs the transaction directly from the JSON payload.
Standalone API flow (copy-paste)
Self-contained Python you can run without this repo. Needs only
pip install requests. Auth is a Utila access token (set
UTILA_SA_ACCOUNT in the environment).
Fill in the constants, set PAYMENT_MODE to "classic" or "sep41",
then run the file.
"""Sponsored Stellar payment: standalone.
pip install requests
"""
from __future__ import annotations
import os
import time
import uuid
import requests
API_BASE = "https://api.utila.io"
VAULT_ID = "YOUR_VAULT_ID"
# Utila access token.
SA_ACCOUNT = os.environ["UTILA_SA_ACCOUNT"]
NETWORK = "networks/stellar-testnet" # or networks/stellar-mainnet
SPONSOR_ADDRESS = "G…" # fee payer (Utila wallet, holds XLM)
SENDER_ADDRESS = "G…" # asset holder (Utila wallet)
DESTINATION_ADDRESS = "G…"
PAYMENT_MODE = "classic" # or "sep41"
# Option A: classic trustline asset
ASSET = "USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" # testnet Circle USDC
# Option B: SEP-41 contract path. Testnet Circle USDC SAC (classic-via-SAC;
# G… destinations still need a USDC trustline):
CONTRACT_ADDRESS = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
RAW_AMOUNT = "1000000" # 0.1 USDC (7 decimals → stroops)
POLL_INTERVAL = 3.0
POLL_TIMEOUT = 600.0
SUCCESS = {"CONFIRMED"}
FAILURE = {
"FAILED", "DECLINED", "DECLINED_BY_AML_POLICY",
"CANCELED", "EXPIRED", "DROPPED", "REPLACED",
}
def headers() -> dict[str, str]:
return {
"Authorization": f"Bearer {SA_ACCOUNT}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def build_operation() -> dict:
if PAYMENT_MODE == "classic":
body = {
"payment": {
"destinationAddress": DESTINATION_ADDRESS,
"asset": ASSET,
"rawAmount": RAW_AMOUNT,
}
}
elif PAYMENT_MODE == "sep41":
body = {
"sep41Payment": {
"destinationAddress": DESTINATION_ADDRESS,
"sourceAddress": SENDER_ADDRESS,
"contractAddress": CONTRACT_ADDRESS,
"rawAmount": RAW_AMOUNT,
}
}
else:
raise ValueError("PAYMENT_MODE must be 'classic' or 'sep41'")
return {"sourceAccountAddress": SENDER_ADDRESS, "body": body}
def initiate() -> str:
payload = {
"requestId": str(uuid.uuid4()),
"details": {
"stellarTransaction": {
"network": NETWORK,
"sourceAddress": SPONSOR_ADDRESS, # fee payer
"operations": [build_operation()],
}
},
"note": f"Sponsored Stellar payment ({PAYMENT_MODE})",
}
r = requests.post(
f"{API_BASE}/v2/vaults/{VAULT_ID}/transactions:initiate",
headers=headers(),
json=payload,
timeout=30,
)
r.raise_for_status()
tx = r.json()["transaction"]
tx_id = tx["name"].rsplit("/", 1)[-1]
print(f"initiated {tx_id} state={tx.get('state')}")
return tx_id
def wait(tx_id: str) -> dict:
deadline = time.monotonic() + POLL_TIMEOUT
last = ""
while time.monotonic() < deadline:
time.sleep(POLL_INTERVAL)
r = requests.get(
f"{API_BASE}/v2/vaults/{VAULT_ID}/transactions/{tx_id}",
headers=headers(),
timeout=30,
)
r.raise_for_status()
tx = r.json()["transaction"]
state = tx.get("state", "")
if state != last:
print(f"{tx_id} -> {state}")
last = state
if state in SUCCESS:
print(f"done hash={tx.get('hash')} (fee paid by sponsor)")
return tx
if state in FAILURE:
raise RuntimeError(f"tx ended in {state}")
raise TimeoutError(f"tx {tx_id} not confirmed within {POLL_TIMEOUT}s")
if __name__ == "__main__":
wait(initiate())Testnet Circle USDC reference:
| Value | |
|---|---|
Classic asset | USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 |
SEP-41 contractAddress | CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA |
Mainnet classic asset | USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN |
Option A: Classic asset payment (recommended default)
Use this when the destination has a classic trustline for the asset. In the standalone snippet set PAYMENT_MODE = "classic".
Submit
POST /v2/vaults/{vault_id}/transactions:initiate
The two fields that make this a sponsored payment:
sourceAddress(transaction-level): the sponsor's Stellar address. Pays the fee and supplies the sequence number.operations[].sourceAccountAddress: the sender's Stellar address. Authorizes the asset movement.
import os
import requests
VAULT_ID = "YOUR_VAULT_ID"
SA_ACCOUNT = os.environ["UTILA_SA_ACCOUNT"]
NETWORK = "networks/stellar-mainnet" # or networks/stellar-testnet
SPONSOR_ADDRESS = "G…"
SENDER_ADDRESS = "G…"
DESTINATION_ADDRESS = "G…"
ASSET = "USDC-G…" # {CODE}-{ISSUER}
RAW_AMOUNT = "1000000"
resp = requests.post(
f"https://api.utila.io/v2/vaults/{VAULT_ID}/transactions:initiate",
headers={
"Authorization": f"Bearer {SA_ACCOUNT}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json={
"details": {
"stellarTransaction": {
"network": NETWORK,
"sourceAddress": SPONSOR_ADDRESS,
"operations": [
{
"sourceAccountAddress": SENDER_ADDRESS,
"body": {
"payment": {
"destinationAddress": DESTINATION_ADDRESS,
"asset": ASSET,
"rawAmount": RAW_AMOUNT,
}
},
}
],
}
},
"note": "Sponsored USDC payment",
"externalId": "your-reference-id",
},
timeout=30,
)
resp.raise_for_status()
tx = resp.json()["transaction"]
print(tx["name"], tx.get("state"))Field reference:
| Field | Required | Notes |
|---|---|---|
network | Yes | networks/stellar-mainnet or networks/stellar-testnet |
sourceAddress | Yes | The sponsor's Stellar address. Pays the fee, supplies the sequence number |
operations[].sourceAccountAddress | Yes | The sender's Stellar address (the account whose asset is moving) |
operations[].body.payment.destinationAddress | Yes | Receiving Stellar address. Must already have a trustline for the asset |
operations[].body.payment.asset | Yes | Format: {CODE}-{ISSUER}. Classic trustline-based asset, not the Soroban contract address |
operations[].body.payment.rawAmount | Yes | Amount in the asset's smallest unit (stroops for 7-decimal assets like USDC: 0.1 USDC = 1000000) |
note | No | Human-readable context, visible in the console |
externalId | No | Your system's reference ID. Use for idempotent correlation |
Option B: SEP-41 variation (contract path)
Use this when you move the asset via a SEP-41 contractAddress (sep41Payment) instead of a classic {CODE}-{ISSUER} payment. In the standalone snippet set PAYMENT_MODE = "sep41" and CONTRACT_ADDRESS.
Whether a trustline is required depends on what kind of token that contractAddress is:
- Pure Soroban contract tokens (custom smart-contract tokens with no classic issuer): trustline is not required. Balances live in contract storage; any existing account can receive immediately without opening a trustline or locking the usual 0.5 XLM trustline reserve.
- Classic tokens via SAC (e.g. Circle USDC): the SEP-41 interface is the protocol Stellar Asset Contract for that classic asset — not a separate Circle-issued wrapper. Trustlines still apply when the destination is a standard wallet (
G…):- Contract → contract: balances move in Soroban contract data; classic trustlines are not involved.
- Contract → wallet (
G…): the recipient must still have a classic trustline for the underlying asset. Without it, the contract call fails.
This payments guide's destinations are Utila / Stellar wallets (G…), so for USDC and other classic-via-SAC assets Option B does not skip the trustline. Prefer Option A for classic USDC unless you specifically need the contract invocation path.
The transaction envelope shape is identical to Option A: same tx-level sourceAddress, same operation-level sourceAccountAddress. Only the operation body changes: payment becomes sep41Payment, and asset: "CODE-ISSUER" becomes contractAddress.
import os
import requests
VAULT_ID = "YOUR_VAULT_ID"
SA_ACCOUNT = os.environ["UTILA_SA_ACCOUNT"]
NETWORK = "networks/stellar-mainnet" # or networks/stellar-testnet
SPONSOR_ADDRESS = "G…"
SENDER_ADDRESS = "G…"
DESTINATION_ADDRESS = "G…"
CONTRACT_ADDRESS = "C…" # Soroban Asset Contract
RAW_AMOUNT = "1000000"
resp = requests.post(
f"https://api.utila.io/v2/vaults/{VAULT_ID}/transactions:initiate",
headers={
"Authorization": f"Bearer {SA_ACCOUNT}",
"Content-Type": "application/json",
"Accept": "application/json",
},
json={
"details": {
"stellarTransaction": {
"network": NETWORK,
"sourceAddress": SPONSOR_ADDRESS,
"operations": [
{
"sourceAccountAddress": SENDER_ADDRESS,
"body": {
"sep41Payment": {
"destinationAddress": DESTINATION_ADDRESS,
"sourceAddress": SENDER_ADDRESS,
"contractAddress": CONTRACT_ADDRESS,
"rawAmount": RAW_AMOUNT,
}
},
}
],
}
},
"note": "Sponsored SEP-41 payment",
},
timeout=30,
)
resp.raise_for_status()
tx = resp.json()["transaction"]
print(tx["name"], tx.get("state"))SEP-41-specific fields:
| Field | Notes |
|---|---|
contractAddress | Soroban Asset Contract address (starts with C…): replaces asset from Option A |
sep41Payment.sourceAddress | Inside the operation body, restates the sender. Must match operations[].sourceAccountAddress |
Choosing between A and B
| Consideration | Classic (Option A) | SEP-41 (Option B) |
|---|---|---|
Trustline on G… destination | Yes | Yes for classic-via-SAC (USDC); no for pure Soroban contract tokens |
| Fee cost | Lower (fixed base fee) | Higher (Soroban resource fees on top of base fee) |
| Ecosystem maturity | Widely supported | Newer; check anchor / off-ramp support |
| Compatibility with classic USDC | ✓ | ✓ via protocol SAC (C…); same classic balance/trustline space |
Default to Option A for classic USDC unless you specifically need the contract path; it's cheaper and better supported by downstream Stellar tooling.
Approvals and signing
After submission, Utila:
- Identifies the required signers from the payload: the tx-level
sourceAddress(sponsor) and eachoperations[].sourceAccountAddress(sender). Both must be wallets in your vault. - Enters the vault's policy/approval flow, moving through
AWAITING_POLICY_CHECKand optionallyAWAITING_APPROVAL. - Once approved, runs two MPC signing sessions (one per signer), fetches the sponsor's current on-chain sequence number, and broadcasts.
Verify
Poll transaction status:
GET /v2/vaults/{vault_id}/transactions/{transaction_id}
import os
import requests
VAULT_ID = "YOUR_VAULT_ID"
TX_ID = "TRANSACTION_ID"
SA_ACCOUNT = os.environ["UTILA_SA_ACCOUNT"]
resp = requests.get(
f"https://api.utila.io/v2/vaults/{VAULT_ID}/transactions/{TX_ID}",
headers={"Authorization": f"Bearer {SA_ACCOUNT}", "Accept": "application/json"},
timeout=30,
)
resp.raise_for_status()
tx = resp.json()["transaction"]
print(tx.get("state"), tx.get("hash"))Watch for state to reach CONFIRMED. The full state flow:
AWAITING_POLICY_CHECK → AWAITING_SIGNATURE → SIGNED → AWAITING_PUBLISH → PUBLISHED → CONFIRMED
Terminal failure states: FAILED, DECLINED (rejected by policy).
Verify on-chain:
Once CONFIRMED, capture the hash field from the transaction resource and look it up on Stellar Expert or Horizon. Confirm:
- The operation succeeded (
op_success) - The destination's asset balance increased by the sent amount
- The fee was charged against the sponsor's XLM balance, not the sender's
FAQs
Destination must have the trustline for classic assets (and for SAC → wallet). Classic Stellar payments do not implicitly create token accounts. If the trustline isn't there, the transaction mines but the operation fails with op_no_trust (classic) or the SAC call fails (SEP-41 to a G… wallet); the fee is still consumed. Use the sponsored account and trustline flow first. Option B only skips the trustline for pure Soroban contract tokens, not for USDC/other classic-via-SAC assets sent to wallets.
Destination account must exist on-chain. Even with a trustline, the account itself must have been funded at least once (minimum 1 XLM base reserve) before it can receive any payment. A completely unfunded address fails with op_no_destination.
Sender does not need XLM. That's the whole point of sponsorship: the sender only needs to hold the asset being sent. The sponsor covers the fee entirely.
asset (classic) vs contractAddress (SEP-41). Choosing the wrong body type for your asset is the most common integration bug. Classic USDC issued by Circle on mainnet is USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN; its protocol SAC is CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75. On testnet, classic USDC is USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 and its SAC is CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA. For the same classic asset, SAC and classic payments share the same trustline balance space, but you must still pick the matching body type (payment vs sep41Payment).
Both wallets must exist in Utila before you submit. Utila resolves signers from the payload. If either address doesn't map to a wallet in the vault, signing fails.
rawAmount units: stroops, not whole units. For 7-decimal assets like USDC, 1 USDC = 10000000 rawAmount. Off-by-a-decimal errors are the most common bug on first integration.
Sponsor needs XLM headroom, not just fee money. If the sponsor is also sponsoring reserves for other accounts (via the sponsored account and trustline flow), each sponsored account + trustline pair locks ~1.5 XLM of minimum balance on the sponsor for the life of the sponsorship. Monitor and top up.
Related
- Stellar sponsored account and trustline sponsor account creation + trustline (required when destination is brand new)
- Stellar Expert on-chain explorer for verifying transactions
- Horizon API docs
