Cavos

Stellar

Use @cavos/kit on Stellar — a classic G… whose spend key is HKDF of a MasterDEK (same wrap as Solana). Relayer sponsors reserves and fees. Soroban and Trustless Work still work.

Stellar is a classic G… account — the same address format wallets, exchanges, and every Stellar tool already understand. With deviceApproval: "enclave" or a passkey (the current path), that G… is HKDF of the same MasterDEK as Solana (cavos-ed25519-stellar-v1). A new device unwraps the DEK. It does not add a second Horizon signer.

It's a native Stellar account (not a Soroban contract), so it interoperates with the whole ecosystem — and it can still sign Soroban contract invocations, so the same G… acts as a require_auth role in on-chain apps like escrows. Gas and account reserves are sponsored by the Cavos relayer.

Grandfathered wallets (no enclave, no passkey at connect) still use extra weight-1 Horizon signers: the first device's random control key is the G…, and a new device is setOptions'd on. That path is load-only. New apps should use enclave or passkey.

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",                    // HKDF salt on the native path — never change it
  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.

On the native DEK path, connect unwraps (or mints) the MasterDEK and imports the Ed25519 control key as a non-extractable WebCrypto key, inside the Cavos vault on the web. The G… is that key's public address. A returning device with a local wrap is ready with no prompt. Grandfathered wallets still mint a random control key whose pubkey is the G….

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 this device's ed25519 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 by default. invokeContract and escrow methods are sponsored: the relayer signs the fee-bump (kind: "soroban") and pays the inclusion fee and the resource fee. Pass { sponsored: false } for the account to pay the fee itself.

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

Native DEK path (enclave / passkey): one spend key, restored by an enclave unwrap or a passkey's encrypted copy. Signing in again with the enrolled Google/Apple identity does restore the account.

Grandfathered path: extra Horizon signers.

FactorNative DEKGrandfathered
DeviceEd25519 imported from the DEKRandom WebCrypto key; first device's pubkey is the G…
PasskeyEncrypted copy of the MasterDEK (can spend)Extra weight-1 signer
EnclaveSeals / unwraps the DEKNot used
Recovery codeNot usedExtra weight-1 signer

On a returning device with a local wrap, connect is ready with no prompt. On a new device with no wrap, the vault asks for the passkey if one was added; otherwise status is needs-device-approval until enclave login or approveDeviceWithPasskey().

Signing up never asks for a passkey. After connect, call enrollPasskeyDefault(). On the native DEK path the passkey stores an encrypted copy of the MasterDEK — anyone with that synced passkey can open the wallet. It is not 2FA and not a Horizon extra signer. wallet.enrollPasskey(...) is a no-op on native accounts. See Passkeys for how the copy is protected.

Use the enclave if you want Google/Apple restore without giving a synced credential spending power by itself. deviceApproval: "passkey" is one chain.

TypeScript
const { enrollPasskeyDefault, approveDeviceWithPasskey, walletStatus } = useCavos();

await enrollPasskeyDefault(); // from a device that holds the wallet

// A new device is asked on sign-in. If the user dismissed it:
if (walletStatus.needsDeviceApproval && walletStatus.hasPasskey) {
  await approveDeviceWithPasskey();
}

Grandfathered wallets only: PasskeyPrf.enroll + wallet.enrollPasskey(secret) still adds a weight-1 Horizon signer. Do not use that on a nativeDek wallet.

Approve a new device

On the native DEK path, a fresh device lands on the same G… with status: "needs-device-approval" until it unwraps the MasterDEK (enclave login or approveDeviceWithPasskey()). There is no setOptions of a second signer.

Grandfathered wallets: unlock with the passkey (or a recovery code) to setOptions this device's ed25519 key onto the account:

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

// Grandfathered only
if (wallet.chain === "stellar" && wallet.status === "needs-device-approval" && !wallet.nativeDek) {
  const prf = new PasskeyPrf({ rpName: "My App" });
  await wallet.approveThisDeviceWithPasskey(await prf.getSecret());
}

See Multi-device and Passkeys.

Recovery

Native DEK: hardware-isolated recovery seals the MasterDEK. Sign in again on the new device, or use approveDeviceWithPasskey(). Do not use a recovery code to restore native accounts.

Grandfathered: a recovery code is an extra Horizon signer:

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

const code = generateRecoveryCode();
await wallet.setupRecovery(code);
// show `code` once; the kit never persists it.
TypeScript
if (wallet.chain === "stellar" && wallet.status === "needs-device-approval" && !wallet.nativeDek) {
  await wallet.approveThisDeviceWithRecovery(code);
}

See Recovery codes for the grandfathered model.

Manage devices

Native DEK: one spend key. listDevices still reads Horizon signers of the classic account (usually just the G… itself). Do not treat extra signers as the restore path.

Grandfathered: a "device" is a Horizon signer. List and revoke:

TypeScript
const signers = await wallet.listDevices();

await wallet.removeDevice({
  slotId, // never this device's own key
});

Revocation is surgical on grandfathered accounts. removeDevice drops that one signer via setOptions (weight 0). You cannot revoke the signer this device is using. Native Solana and Starknet have no equivalent: one key, restored from the MasterDEK.

Pay reserves and fees in a token

The kit relayer (appId) sponsors XLM so the user never holds lumens — Cavos pays. That is the default for execute, trustlines, fee-bumps, and invokeContract.

If the user should pay those same costs in USDC (or another allowlisted asset), use @cavos/reserve and sign with wallet.signXdr. Do not also call wallet.execute to create the account: that would take the relayer path instead.

TypeScript
import { Reserve } from "@cavos/reserve";

const reserve = await Reserve.connect("testnet");

if (wallet.chain === "stellar") {
  await reserve.pay(
    {
      source: wallet.address,
      destination,
      amount: "10",
      token: "USDC:GCKUFD5KAAM6DRSLODK55OVECMB5IJ5NSFQYFTBZRPOTJASUKTBZXGS2",
      maxSend: "0.05",
    },
    (xdr) => wallet.signXdr(xdr),
  );
}

Reserve is wallet-agnostic (Freighter works the same way) and does not need a Cavos appId. Classic operations only — no Soroban. See Reserve for activate-from-a-claimable (a G… that holds 0 XLM), verification, and the token allowlist.

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 extra signers). 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: (userId, appId, chain, network).
  2. If found: that G… is returned. Native: unwrap the MasterDEK or land on needs-device-approval. Grandfathered: check whether this device already holds a Horizon signer.
  3. If not found: native mints a random MasterDEK and claims the HKDF G…. Grandfathered mints a random control key whose pubkey is the G…. The first device to register wins.

The address is available before the account exists on-chain. The on-chain account is created on first execute.

TypeScript
const wallet = session.wallet("stellar");
console.log(wallet.address);  // G… address
console.log(wallet.status);   // "undeployed" until first execute

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. Native MasterDEK path shares the DEK with Solana.
  • ✅ Grandfathered per-device Horizon signers still load for older wallets.
  • ✅ Lazy deploy — first execute creates the account atomically.
  • ✅ Silent signing via a non-extractable ed25519 control key, held by the Cavos vault within your app's limits.
  • ✅ 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.
  • ✅ Enclave DEK wrap and passkey restore on the native path.
  • ✅ Pay in a token via @cavos/reserve (wallet.signXdr).

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

On this page