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.
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
| Status | Meaning | Can execute? |
|---|---|---|
undeployed | Address resolved, no on-chain account yet | Yes — first execute creates + runs atomically |
ready | Account exists and this device can sign | Yes |
needs-device-approval | Account exists but this device not authorized | No — 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.
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):
const stroops = await wallet.balance(); // bigint, native XLMSoroban 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:
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:
await wallet.addTrustline({ code: "USDC", issuer: "GA...ISSUER" });
const bal = await wallet.tokenBalance({ code: "USDC", issuer: "GA...ISSUER" }); // "0" if noneWhen 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:
const signedXdr = await wallet.signXdr(unsignedXdr); // your backend submits itIt 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:
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:
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:
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:
| Factor | Purpose | How it's derived |
|---|---|---|
| Device (P-256 ECIES) | Silent daily signing on a known device | This device's ECDH key unwraps its own on-chain slot |
| Passkey (WebAuthn PRF) | Synced anchor to approve a new device / recover | A synced passkey's PRF output derives the KEK |
| Recovery code | Offline 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).
Add a passkey (recommended)
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.
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-chainFor 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:
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:
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.
// 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:
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
| Path | When | Who pays |
|---|---|---|
| Relayer (gasless, default) | appId is set | The Cavos relayer is the tx source + fee payer and sponsors the account's reserves |
| Self-funded fallback | no appId, a stellarSourceKeypair is passed | Your 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:
- Registry lookup: The kit checks if this
(userId, appId, chain, network)already has a registered address. - If found: The existing address is returned. The device checks whether it can unlock the on-chain control key.
- 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.
// 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 yetNetworks & constants
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 ownrequire_authentry, 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 theTrustlessWorkEscrowwrapper (fund → deliver → approve → release, plus disputes) withgetEscrowlive 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.