npx skills add ...
npx skills add stellar/stellar-dev-skill --skill assets
Stellar Assets (classic) + trustlines + Stellar Asset Contract (SAC) bridge to smart contracts. Covers asset issuance, distribution, authorization flags, clawback, regulated assets, trustline management, and the SAC interop layer that exposes classic assets as SEP-41 contract tokens. Use when tokenizing real-world assets, issuing stablecoins, managing trustlines, or bridging classic assets to smart contracts.
npx skills add stellar/stellar-dev-skill --skill assets
Stellar's native token mechanism: classic asset issuance, trustlines, and the Stellar Asset Contract (SAC) bridge that makes classic assets usable from smart contracts. Default to classic assets over custom contract tokens unless you need custom logic.
../smart-contracts/SKILL.md../dapp/SKILL.md../data/SKILL.md../standards/SKILL.mdStellar has two token mechanisms:
Recommendation: Prefer Stellar Assets unless you need custom token logic.
| Type | Description |
|---|---|
| Native (XLM) | Stellar's native currency, no trustline needed |
| Credit | Issued by an account, requires trustline |
| Liquidity Pool Shares | Represent LP positions |
For fixed-supply tokens, lock the issuer:
Configure issuer account flags for compliance:
| Flag | Effect |
|---|---|
AUTH_REQUIRED | Users must get approval before receiving tokens |
AUTH_REVOCABLE | Issuer can freeze user balances |
AUTH_IMMUTABLE | Flags cannot be changed (permanent) |
AUTH_CLAWBACK_ENABLED | Issuer can clawback tokens from accounts |
ChangeTrust with limit: "0" deletes a trustline, but only if the trustline
is in a deletable state. Before submitting:
ChangeTrust. Claimable balances are removed by claiming or clawing them
back, not by ChangeTrust.0 — send
remaining funds back to the issuer (burns them) or to another account.manageSellOffer
with amount: "0", manageBuyOffer with buyAmount: "0").liquidity_pool_use_count > 0, per CAP-0038) cannot be
deleted — withdraw from the pool and remove the pool-share trustline first.SAC provides a smart-contract interface for Stellar Assets, enabling smart contract interactions.
SAC implements the standard SEP-41 token interface:
balance(id: Address) -> i128transfer(from: Address, to: Address, amount: i128)approve(from: Address, spender: Address, amount: i128, expiration_ledger: u32)allowance(from: Address, spender: Address) -> i128decimals() -> u32name() -> Stringsymbol() -> SymbolReach for a fully custom token as a last resort. Many of the cases above (royalties, fees, transfer restrictions) can be built with a regular issued Stellar asset plus a thin "SAC admin" contract that drives the SAC's
mint,burn, andclawbackfunctions. That keeps the ecosystem compatibility of a normal asset (DEX, anchors, wallets, trustlines) while still layering on custom logic. Prefer a custom contract token only when you need token behavior the SAC genuinely cannot express.This runs in production today. USDT0 (
USDT0:GATISXX6BZ6NC7IKQBY37CJD4SOZL3CYZJWXEDG6JVIY4WBS6KXJHN6Q) is a classic asset whose SAC admin is a contract: a role-gated manager that forwardsmint,clawback,set_authorized, andset_adminto the SAC, with a cross-chain bridge holding only the minter role. See setting a custom SAC admin for the pattern and../cross-chain/layerzero.mdfor that deployment.One hard prerequisite: lock the issuer (master key weight
0). Payments from a classic issuer are minting, so an unlocked issuer can mint outside the contract and bypass the role model entirely.Do it in this order:
set_adminfirst, then lock the issuer. A SAC's admin starts as the issuer account, and only the current admin can authorize the firstset_admin. Lock the issuer before that call and the admin stays the locked issuer forever, because nobody can sign the handover (SAC admin guide). After the handover the SAC still mints under the new admin, so lock the issuer then.
Publish asset metadata in your domain's /.well-known/stellar.toml:
Authenticate users with their Stellar accounts. Flow: the server generates a challenge transaction, the client signs it with their wallet, and the server verifies the signature. See SEP-0010.
For fiat on/off ramps: an interactive webview flow where the anchor handles KYC and the fiat transfer. See SEP-0024.
Extends SEP-10 to support contract accounts (C... addresses) for web authentication. Required for smart wallet / passkey-based anchor integrations. Draft status; verify current status in SEP-0045.
Standard contract interface for NFTs on Stellar. Reference implementations available in OpenZeppelin Stellar Contracts with Base, Consecutive, and Enumerable variants. Draft status; verify current status in SEP-0050.
limit: "0"): zero the balance, cancel offers
buying the asset, and exit liquidity pools that reference itstellar.toml at all. A missing home_domain
is not evidence of a scam — USDT0 ships without one. Validate by issuer
plus SAC derivation, never by the presence of home_domain or a
[[CURRENCIES]] entryAUTH_REVOCABLE and AUTH_CLAWBACK_ENABLED are both set, check the
issuer lock and the admin roles before listing the asset. Those flags
mean balances can be frozen or clawed back. A contract SAC admin does not
contain that power on its own: an issuer whose master key still signs can
mint, freeze and claw back directly, whatever the admin contract allows. So
confirm the master key weight is 0, then identify who holds the admin
contract's rolesimport * as StellarSdk from "@stellar/stellar-sdk";
const server = new StellarSdk.Horizon.Server("https://horizon-testnet.stellar.org");
// 1. Create issuing account (should be separate from distribution)
const issuerKeypair = StellarSdk.Keypair.random();
const distributorKeypair = StellarSdk.Keypair.random();
// 2. Fund accounts (testnet)
await fetch(`https://friendbot.stellar.org?addr=${issuerKeypair.publicKey()}`);
await fetch(`https://friendbot.stellar.org?addr=${distributorKeypair.publicKey()}`);const asset = new StellarSdk.Asset("MYTOKEN", issuerKeypair.publicKey());
// 1. Distributor creates trustline to issuer
const distributorAccount = await server.loadAccount(distributorKeypair.publicKey());
const trustlineTx = new StellarSdk.TransactionBuilder(distributorAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.changeTrust({
asset: asset,
limit: "1000000", // Max amount to hold
})
)
.setTimeout(180)
.build();
trustlineTx.sign(distributorKeypair);
await server.submitTransaction(trustlineTx);
// 2. Issuer sends tokens to distributor
const issuerAccount = await server.loadAccount(issuerKeypair.publicKey());
const issueTx = new StellarSdk.TransactionBuilder(issuerAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.payment({
destination: distributorKeypair.publicKey(),
asset: asset,
amount: "1000000",
})
)
.setTimeout(180)
.build();
issueTx.sign(issuerKeypair);
await server.submitTransaction(issueTx);const lockTx = new StellarSdk.TransactionBuilder(issuerAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.setOptions({
masterWeight: 0, // Disable master key
})
)
.setTimeout(180)
.build();
lockTx.sign(issuerKeypair);
await server.submitTransaction(lockTx);
// Issuer can never issue more tokensconst setFlagsTx = new StellarSdk.TransactionBuilder(issuerAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.setOptions({
setFlags:
StellarSdk.AuthRequiredFlag | // Trustlines require approval
StellarSdk.AuthRevocableFlag | // Can freeze trustlines
StellarSdk.AuthClawbackEnabledFlag, // Can clawback tokens
})
)
.setTimeout(180)
.build();// When AUTH_REQUIRED is set, approve trustlines:
const authorizeTx = new StellarSdk.TransactionBuilder(issuerAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.setTrustLineFlags({
trustor: userPublicKey,
asset: asset,
flags: {
authorized: true,
// authorizedToMaintainLiabilities: true, // Partial auth
},
})
)
.setTimeout(180)
.build();// Requires AUTH_CLAWBACK_ENABLED flag
const clawbackTx = new StellarSdk.TransactionBuilder(issuerAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.clawback({
asset: asset,
from: targetAccountId,
amount: "100",
})
)
.setTimeout(180)
.build();const changeTrustTx = new StellarSdk.TransactionBuilder(userAccount, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.changeTrust({
asset: asset,
limit: "10000", // Max amount to hold (see "Remove Trustline" for limit: "0")
})
)
.setTimeout(180)
.build();// 1. Check the trustline is deletable
const account = await server.loadAccount(userPublicKey);
const trustline = account.balances.find(
(b) =>
b.asset_type !== "native" &&
b.asset_type !== "liquidity_pool_shares" &&
b.asset_code === asset.getCode() &&
b.asset_issuer === asset.getIssuer()
);
if (!trustline) throw new Error("No trustline (display/claimable state only?)");
if (parseFloat(trustline.balance) !== 0)
throw new Error("Balance must be 0 — send funds away or back to issuer");
if (parseFloat(trustline.buying_liabilities) !== 0)
throw new Error("Cancel open offers buying this asset first");
// Liquidity-pool usage (precondition 4) is not visible on this balance line —
// if the asset is still in one of the account's pools, the submit below fails
// with op_cannot_delete. Withdraw and remove the pool-share trustline first.
// 2. Submit removal and surface the specific result code
const removeTrustTx = new StellarSdk.TransactionBuilder(account, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(
StellarSdk.Operation.changeTrust({
asset: asset,
limit: "0", // Delete the trustline
})
)
.setTimeout(180)
.build();
removeTrustTx.sign(userKeypair);
try {
await server.submitTransaction(removeTrustTx);
} catch (e) {
const codes = e.response?.data?.extras?.result_codes?.operations ?? [];
if (codes.includes("op_invalid_limit")) {
// CHANGE_TRUST_INVALID_LIMIT: balance or buying liabilities remain
throw new Error("Clear balance and open offers before removing trustline");
}
if (codes.includes("op_cannot_delete")) {
// CHANGE_TRUST_CANNOT_DELETE: trustline is used by a liquidity pool
throw new Error("Withdraw from liquidity pools referencing this asset first");
}
throw e;
}const account = await server.loadAccount(userPublicKey);
const trustline = account.balances.find(
(b) =>
b.asset_type !== "native" &&
b.asset_code === "USDC" &&
b.asset_issuer === usdcIssuer
);
if (trustline) {
console.log("Balance:", trustline.balance);
console.log("Limit:", trustline.limit);
console.log("Authorized:", trustline.is_authorized);
}# Get the SAC address for an asset
stellar contract asset deploy \
--asset USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN \
--source-account alice \
--network testnetimport * as StellarSdk from "@stellar/stellar-sdk";
const asset = new StellarSdk.Asset("USDC", issuerPublicKey);
const contractId = asset.contractId(StellarSdk.Networks.TESTNET);
// Returns the deterministic SAC contract addressuse soroban_sdk::{token::Client as TokenClient, Address, Env};
pub fn transfer_asset(
env: Env,
from: Address,
to: Address,
asset_contract: Address,
amount: i128,
) {
from.require_auth();
// Use standard token interface
let token = TokenClient::new(&env, &asset_contract);
token.transfer(&from, &to, &amount);
}const account = await server.loadAccount(publicKey);
for (const balance of account.balances) {
if (balance.asset_type === "native") {
console.log("XLM:", balance.balance);
} else {
console.log(`${balance.asset_code}:`, balance.balance);
}
}// Search for assets by code
const assets = await server
.assets()
.forCode("USDC")
.call();
// Get specific asset details
const assetDetails = await server
.assets()
.forCode("USDC")
.forIssuer(issuerPublicKey)
.call();const stats = await server
.assets()
.forCode("USDC")
.forIssuer(issuerPublicKey)
.call();
// stats includes:
// - amount: total issued
// - num_accounts: trustline count
// - flags: issuer flags[[CURRENCIES]]
code = "MYTOKEN"
issuer = "GABC..."
display_decimals = 2
name = "My Token"
desc = "A description of my token"
image = "https://example.com/token-logo.png"