Cavos

Stellar

Use @cavos/kit on Stellar — a classic G… multisig account whose control key is sealed on-chain and unlocked by a silent device key, gasless via the Cavos relayer, and able to sign Soroban contract calls and Trustless Work escrows.

Stellar is one of the current adapters in Cavos' every-chain architecture. @cavos/kit gives your users a standard G… account — the same address format wallets, exchanges, and every Stellar tool already understand — with no seed phrase and no popups. A login derives a G… address, and transactions are signed silently on the device. Gas and account reserves are sponsored by the Cavos relayer, so the user never needs to fund or manage XLM to get started.

It's a native Stellar account (not a Soroban contract), so it interoperates with the whole ecosystem out of the box — and it can still sign Soroban contract invocations, so the same G… account acts as a require_auth role in on-chain apps like escrows. If you've used the Starknet or Solana paths, the app-facing API is identical — same Cavos.connect, same silent signing, same recovery model.

How it works underneath (optional). The G… account is a multisig: a control key (weight 1) does the signing, sealed on-chain in the account's own data entries and unwrapped locally on each device, so signing stays silent and the account stays self-custodial — no backend ever holds a key. You don't need to manage any of this; Cavos.connect handles it.

Connect

Use the unified Cavos.connect, passing chains: ["stellar"]. An appId activates the gasless relayer (it sponsors the account's XLM reserves and pays fees). Connect never creates the account — the account is created lazily on the first execute.

TypeScript
import { Cavos } from "@cavos/kit";

const session = await Cavos.connect({
  chains: ["stellar"],
  defaultChain: "stellar",
  network: "testnet",                   // "testnet" | "mainnet"
  appSalt: "my-app",                    // device-key namespace
  identity: {                           // from your login (see Authentication)
    userId: user.id,
    email: user.email,
  },
  appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, // activates the gasless relayer
});

const wallet = session.wallet("stellar");
console.log(wallet.address);            // G… address
console.log(wallet.status);             // "undeployed" | "ready" | "needs-device-approval"

network: "testnet" resolves to stellar-testnet; "mainnet" resolves to stellar-mainnet. The returned wallet is discriminated by wallet.chain.

Cavos.connect provisions this device's ECDH unwrap key for you (a persisted, non-extractable P-256 key via WebCryptoDeviceUnwrapKey in the browser). On React Native / server, pass your own stellarDeviceKey. The low-level CavosStellar.connect requires an explicit deviceKey.

Status values

StatusMeaningCan execute?
undeployedAddress resolved, no on-chain account yetYes — first execute creates + runs atomically
readyAccount exists and this device can signYes
needs-device-approvalAccount exists but this device not authorizedNo — route to device approval

Lazy deploy: An undeployed wallet can sign messages and execute. The first execute creates the account (sponsored, 0 XLM cost to the user), then performs the operation. After that, status becomes ready.

Execute a transfer

On Stellar, execute(amount, destination) moves amount stroops of native XLM out of the account to destination. Both undeployed and ready wallets can execute — the first execution creates the account atomically.

TypeScript
if (wallet.chain === "stellar" && wallet.status !== "needs-device-approval") {
  const hash = await wallet.execute(
    10_000_000n,                         // 1 XLM, in stroops
    "GDESTINATION...ADDRESS",            // recipient G… address
  );
  console.log(hash);                     // Horizon transaction hash
}

When the account is undeployed, this call creates the account first (sponsored, 0 XLM cost), then performs the payment. After that, status becomes ready.

amount is a bigint of stroops (1 XLM = 10_000_000 stroops). Pass { sponsored: false } to submit directly — the account pays its own (tiny) fee from its XLM balance instead of the relayer.

Read the native balance with wallet.balance() (stroops):

TypeScript
const stroops = await wallet.balance();  // bigint, native XLM

Soroban contracts & escrows

Beyond native XLM transfers, the account can invoke Soroban contracts. Because the control key is a real Stellar signer, it can satisfy a contract's require_auth for this account's address — so your G… account participates in on-chain apps (escrows, marketplaces, DeFi) as a first-class signer, still silent and still gasless.

invokeContract builds and simulates the call, re-signs only the Soroban auth entry belonging to this account with the control key (other roles' entries are untouched), and submits it — sponsored by the relayer by default:

TypeScript
if (wallet.chain === "stellar" && wallet.status === "ready") {
  const hash = await wallet.invokeContract({
    contractId: "C...ESCROW",             // the Soroban contract
    method: "approve_milestone",
    args: [0, wallet.address],            // ScVals, or JS values auto-converted
  });
}

Soroban invocations require a deployed account. Unlike native XLM transfers (which auto-create the account), invokeContract and addTrustline require status === "ready". Call execute() first to create the account if needed.

To hold or receive a classic asset (e.g. USDC — needed to fund a USDC escrow), open a trustline first. A trustline adds a subentry (reserve); sponsored by the relayer by default:

TypeScript
await wallet.addTrustline({ code: "USDC", issuer: "GA...ISSUER" });
const bal = await wallet.tokenBalance({ code: "USDC", issuer: "GA...ISSUER" }); // "0" if none

When your backend (or a platform's REST API) builds the transaction and hands you an unsigned XDR, use signXdr instead. It mirrors a classic wallet's signTransaction(unsignedXdr) → signedXdr, so a Cavos account drops into any flow that already expects one — it does not submit:

TypeScript
const signedXdr = await wallet.signXdr(unsignedXdr); // your backend submits it

It handles both auth models: Soroban auth entries naming this account are re-signed (authorizeEntry), source-account auth and classic transactions are satisfied by the envelope signature. Entries authorizing other addresses are left untouched.

Trustless Work escrows

@cavos/kit ships a first-class wrapper for Trustless Work milestone escrows — a Soroban platform where auth is spread across six roles (approver, service_provider, platform, release_signer, dispute_resolver, receiver), each an address that signs contract calls. A Cavos account can hold any of these roles: the wrapper passes this account's G… as the role argument and its control key signs the matching auth entry.

Deploy a fresh escrow through the Trustless Work factory, signed by a Cavos account:

TypeScript
import { deployEscrow, StellarAdapter } from "@cavos/kit";

const adapter = new StellarAdapter({ network: "testnet" });

const { escrowId } = await deployEscrow(wallet, {
  factoryId: "C...FACTORY",
  wasmHash: "96e4eb6a...",                // the escrow contract's wasm hash
  adapter,
  escrow: {
    engagementId: "job-42",
    title: "Landing page",
    description: "Design + build",
    amount: 2_000_000n,                    // total, in the token's base units (7-dp)
    platformFeeBps: 100,                   // 1%
    roles: {
      approver: client.address,
      serviceProvider: freelancer.address,
      platform: client.address,
      releaseSigner: client.address,
      disputeResolver: client.address,
      receiver: freelancer.address,
    },
    milestones: [{ description: "Ship v1" }],
    trustline: "C...USDC",                 // the token SAC (native XLM SAC needs no trustline)
  },
});

Then drive the lifecycle. Each method acts as the role it maps to, so it must be called by the account holding that role:

TypeScript
import { TrustlessWorkEscrow, buildEscrowScVal } from "@cavos/kit";

const escrow = new TrustlessWorkEscrow(wallet, escrowId);

// Client funds the escrow (fund BEFORE any milestone change — the expected
// struct must match stored state; rebuild it from the same EscrowInput):
await escrow.fundEscrow(buildEscrowScVal(escrowInput), 2_000_000n);

// Service provider marks work delivered:
await escrow.changeMilestoneStatus(0, "delivered");

// Client approves:
await escrow.approveMilestone(0);

// Once every milestone is approved and the escrow is funded, release funds —
// pays the receiver, minus the Trustless Work + platform fees:
await escrow.releaseFunds("G...TRUSTLESS_WORK");

// Read live on-chain state at any time:
const state = await escrow.getEscrow(adapter);
console.log(state.flags.released, state.milestones[0].approved);

disputeEscrow() and resolveDispute(...) cover the dispute path. Every method is gasless by default; pass { sponsored: false } to have the account pay its own fee.

Gasless status. Native XLM execute and trustlines are sponsored by the relayer today. Soroban invocations (invokeContract, escrow methods) sign silently and route through the relayer for a fee-bump when available; backend co-signing of Soroban calls is still being wired, so on testnet you may run these with { sponsored: false } against a self-funded account.

Sign messages

Sign arbitrary messages off-chain with the control key. Works on both undeployed and ready wallets — the control key is generated at connect time:

TypeScript
const sig = await wallet.signMessage("Hello, Cavos!");
// sig.signature: Uint8Array
// sig.publicKey: "G..." (control key's G address)
// sig.curve: "ed25519"

A verifier calls Keypair.fromPublicKey(controlAddress).verify(messageBytes, signature) — standard ed25519 math. The message is prefixed with the Cavos domain prefix before signing.

Unlock factors & silent signing

The control key's seed is sealed under a DEK (data-encryption key), and that DEK is wrapped once per unlock factor. Opening any single factor yields the same DEK → the same control key:

FactorPurposeHow it's derived
Device (P-256 ECIES)Silent daily signing on a known deviceThis device's ECDH key unwraps its own on-chain slot
Passkey (WebAuthn PRF)Synced anchor to approve a new device / recoverA synced passkey's PRF output derives the KEK
Recovery codeOffline backup (optional)A stored code derives the KEK

On a returning device, connect unwraps the device slot and the wallet is ready — no prompt. On a new device there is no device slot yet, so status is needs-device-approval until the user approves it with a passkey or recovery code (below).

A passkey is the synced factor that lets a user approve a new device without finding an already-authorized one. On Stellar it is a WebAuthn PRF credential whose derived secret wraps the account DEK — it is not an on-chain signer and not part of the cross-chain approveDeviceEverywhere batch.

TypeScript
import { PasskeyPrf } from "@cavos/kit";

// Right after signup — run once to enroll the passkey:
const prf = new PasskeyPrf({ rpName: "My App" });
const { secret } = await prf.enroll({
  userId: user.id,
  userName: user.email ?? user.id,
});
await wallet.enrollPasskey(secret);       // writes the cv:wp factor on-chain

For undeployed wallets, enrollPasskey stores the passkey wrap pending and creates the account (sponsored, 0 XLM cost). This ensures the passkey factor is never lost to a page refresh before the first execute.

The React CavosProvider wraps this as enrollPasskeyDefault() / approveDeviceWithPasskey(), so app code never touches PasskeyPrf directly.

Approve a new device

When the same identity connects on a fresh device, it lands on the same G… address with status: "needs-device-approval". Unlock with the passkey (or a recovery code) to wrap the DEK to this device's slot — future sessions then unlock silently:

TypeScript
const session = await Cavos.connect({
  chains: ["stellar"],
  defaultChain: "stellar",
  /* ...same identity + appSalt... */
});
const wallet = session.wallet("stellar");

if (wallet.chain === "stellar" && wallet.status === "needs-device-approval") {
  const prf = new PasskeyPrf({ rpName: "My App" });
  await wallet.approveThisDeviceWithPasskey(await prf.getSecret());
  // wallet is now ready; no second device required.
}

The unlocking device can itself sign the on-chain write that adds its own slot — because any factor yields the control key, there is no trip back to an old device. See Multi-device for the cross-chain model.

Recovery

Set up a recovery code as an offline backup factor. It wraps the same DEK, so it can unlock the account after every device is lost:

TypeScript
import { generateRecoveryCode } from "@cavos/kit";

// On any device — run once, have the user store the code:
const code = generateRecoveryCode();
await wallet.setupRecovery(code);
// → show `code` to the user once; the kit never persists it.

For undeployed wallets, setupRecovery stores the recovery wrap pending. It gets included when the account is created on first execute.

TypeScript
// After losing every device — reconnect on the new device, then approve it:
const session = await Cavos.connect({
  chains: ["stellar"],
  defaultChain: "stellar",
  /* ...same identity + appSalt... */
});
const wallet = session.wallet("stellar");

if (wallet.chain === "stellar" && wallet.status === "needs-device-approval") {
  await wallet.approveThisDeviceWithRecovery(code);
  // wallet is now ready, bound to the new device.
}

Recovery is self-custodial: the code never leaves the device and only its DEK-wrap is stored on-chain. Cavos never sees the code. See Recovery codes for the model.

Manage devices

A "device" on classic Stellar is an ECIES wrap of the DEK in the account's data entries, not an on-chain signer. List the slots and revoke one:

TypeScript
const slots = await wallet.listDevices();      // slot ids; this device is deviceKey.slotId()

await wallet.removeDevice({
  slotId,                                      // never this device's own slot
  passkeyPrfOutput: await prf.getSecret(),     // carry the passkey factor over
  recoveryCode,                                // carry the recovery factor over
});

Revocation on Stellar evicts every other device, not just the revoked one. A wrap is ECIES to each device's public key, which is never stored on-chain, so the other wraps cannot be recreated. removeDevice rotates in one transaction: it deletes every cv: entry, writes a fresh DEK-sealed control seed with a wrap for this device only, and swaps in a new weight-1 control signer (zeroing the old one). Every other device must be approved again.

The passkey and recovery factors are KEK-derived, so they survive only if the user presents them in this call. Prompt for the passkey first — otherwise the user loses their synced anchor and this device becomes the only way in.

This is what makes revocation actually revoke: the evicted device may already have cached the control seed, so erasing its wrap alone would change nothing. Rotating the control key is the only honest eviction. On Starknet and Solana, where devices are real on-chain signers, removeSigner(pubkey) is surgical.

Gasless vs self-funded

PathWhenWho pays
Relayer (gasless, default)appId is setThe Cavos relayer is the tx source + fee payer and sponsors the account's reserves
Self-funded fallbackno appId, a stellarSourceKeypair is passedYour funded keypair funds reserves + pays fees

Every Stellar account locks XLM reserves (a base reserve plus ~0.5 XLM per subentry — data entries and the control signer). With the relayer, those reserves and all fees are sponsored, so the user locks no XLM. The relayer is only a fee payer + reserve sponsor — never a custodian or identity authority: a bad or absent relayer can cost fees but can never move funds or squat an address.

Address resolution

On Stellar, the G… address is resolved via the registry-first model (see Concepts). When you call connect:

  1. Registry lookup: The kit checks if this (userId, appId, chain, network) already has a registered address.
  2. If found: The existing address is returned. The device checks whether it can unlock the on-chain control key.
  3. If not found: A new control key is generated. Its public key becomes the G… address, and this address is claimed in the registry. The first device to register wins.

The address is computable before the account exists on-chain — off-chain signing works immediately. The on-chain account is created on first execute.

TypeScript
// The address is available immediately after connect, even if undeployed
const wallet = session.wallet("stellar");
console.log(wallet.address);  // G… address
console.log(wallet.status);   // "undeployed" — no on-chain account yet

Networks & constants

TypeScript
import { STELLAR_NETWORKS, HORIZON_URL, XLM_DECIMALS } from "@cavos/kit";

STELLAR_NETWORKS["stellar-testnet"];     // network config
HORIZON_URL["stellar-mainnet"];          // https://horizon.stellar.org
XLM_DECIMALS;                            // 7 (1 XLM = 10^7 stroops)

Networks: "stellar-testnet", "stellar-mainnet".

What's available

  • ✅ Classic G… account with on-chain sealed control key, self-custodial.
  • Lazy deploy — first execute creates the account atomically.
  • ✅ Silent signing via the control key unwrapped by this device's ECDH key.
  • Sign messages off-chain while undeployed.
  • ✅ Gasless create + execute via the Cavos relayer (reserves sponsored).
  • ✅ Native XLM transfer (execute(amount, destination)) + balance().
  • Soroban contract invocation (invokeContract) — the account signs its own require_auth entry, so it acts as a role in on-chain apps, plus USDC-style trustlines (addTrustline / tokenBalance).
  • Trustless Work escrows out of the box: deployEscrow (via factory) and the TrustlessWorkEscrow wrapper (fund → deliver → approve → release, plus disputes) with getEscrow live state reads.
  • Passkey (WebAuthn PRF) and recovery-code unlock factors for approving new devices and recovering after total device loss.

For the cross-chain model and how this compares to the other chains, see Chains and Concepts.

On this page