Cavos: embedded Stellar wallets
Cavos (@cavos/kit) gives each user a classic Stellar G… account inside your app. The user signs in
with Google, Apple, GitHub, Telegram, or an email code. The account key is derived from a per-user
MasterDEK on the user's device, so there is no seed phrase and no extension. The Cavos relayer
sponsors the account's XLM reserves and pays fees, so the user never needs to hold lumens.
The same package also ships Solana and Starknet adapters. This skill covers Stellar only.
- Docs: https://docs.cavos.xyz/docs/stellar (agent index: https://docs.cavos.xyz/llms.txt)
- Package: https://www.npmjs.com/package/@cavos/kit
- Dashboard (app ID, gas, callback URLs): https://cavos.xyz/dashboard
When to use this skill
- The app needs Stellar accounts for users who do not have a wallet
- Onboarding must be gasless: the app sponsors reserves and fees
- The account must be a plain classic
G…that any Stellar tool, exchange, or anchor understands - The same account must also sign Soroban
require_authentries (escrows, marketplaces, DeFi)
When NOT to use this skill
- The user already has Freighter or another extension wallet: use Stellar Wallets Kit
- You need a Soroban contract account (
C…) with on-chain policies: use Smart Account Kit - Server-side signing for your own treasury: Cavos keys belong to end users, not your backend
Install
npm install @cavos/kit
Create an app in the dashboard and take its App ID. Add your exact origin under Dashboard → App → Settings → Callback URLs (scheme, host, and path; no wildcards, no trailing slash). Fund Stellar gas under Dashboard → Gas → Stellar before using mainnet.
Mental model
| Piece | What it does | What it cannot do |
|---|---|---|
| MasterDEK | Per-user secret. The G… key is HKDF of it (cavos-ed25519-stellar-v1). | |
| Device | Holds the Ed25519 account key and signs. | |
Cavos vault (web; on by default in the React provider with appId, opt-in with Cavos.connect) | An iframe on a Cavos origin, running in the user's browser, that keeps the key in its IndexedDB as a non-extractable WebCrypto key. Your page asks it for signatures, within the limits set in the dashboard. | Your page never reads the key. |
Relayer (appId) | Fee payer and reserve sponsor: it signs only as the sponsor or the fee-bump source. | It cannot authorize the account's own operations or move its funds. |
| Enclave (optional) | AWS Nitro enclave that holds a sealed copy of the MasterDEK, opens it only inside the enclave, and re-encrypts it to a new device of the same signed-in identity. | The enclave does not sign transactions. Because it opens the MasterDEK during restore, it is part of the trust model. |
| Passkey (optional) | Stores an encrypted copy of the MasterDEK. | It is not 2FA. Anyone holding the synced passkey can open the wallet. |
In the browser the key is a non-extractable WebCrypto key, inside the Cavos vault by default. This skill covers the browser only: on React Native and Node, the Stellar adapter does not yet use the MasterDEK path described here.
Setup (React / Next.js)
The provider is client-only. In Next.js App Router, put 'use client' at the top of the file that
renders it and of every component that calls useCavos().
'use client';
import { CavosProvider } from '@cavos/kit/react';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<CavosProvider
config={{
appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID,
chains: ['stellar'],
network: 'testnet', // 'testnet' | 'mainnet'
appSalt: 'my-app', // HKDF salt. Never change it after users connect.
socialRecovery: true, // restore on a new device by signing in again
deviceApproval: 'enclave',
}}
modal={{ appName: 'My App', theme: 'light' }}
>
{children}
</CavosProvider>
);
}
'use client';
import { useCavos } from '@cavos/kit/react';
export function WalletButton() {
const { isAuthenticated, isLoading, address, openModal, logout } = useCavos();
if (isLoading) return null;
if (!isAuthenticated) return <button onClick={openModal}>Sign in</button>;
return <button onClick={logout}>{address}</button>;
}
To use your own auth instead of the Cavos modal, pass identity={{ userId, email }} to the provider.
Derive userId from something that never changes.
Get the Stellar wallet
const { wallet } = useCavos();
if (wallet?.chain !== 'stellar') return;
wallet.address; // G…
wallet.status; // 'undeployed' | 'ready' | 'needs-device-approval'
| Status | Meaning |
|---|---|
undeployed | Address is known, the account does not exist on-chain yet. |
ready | The account exists and this device can sign. |
needs-device-approval | The user has a wallet, but this device holds no key yet. Restore with an enclave sign-in or approveDeviceWithPasskey(). |
Connecting never creates the account. On a new account:
create()creates it and sends nothing. The creation is sponsored, so the account starts with 0 XLM. Call it first in an app that starts with a trustline or a Soroban call.execute(amount, to)creates it, then sendsamountXLM from it. With 0 XLM the payment fails withop_underfunded, but the account is created.addTrustlineandinvokeContractthrow until the account exists.
Actions
// Create the account. Does nothing if it is already ready.
await wallet.create();
// Native XLM payment, amount in stroops (1 XLM = 10_000_000n). Fee sponsored by default.
const hash = await wallet.execute(10_000_000n, 'GDEST…');
// Pay the fee from the account's own XLM instead of the relayer.
await wallet.execute(10_000_000n, 'GDEST…', { fee: 'self' });
// Balances
const stroops = await wallet.balance();
const usdc = await wallet.tokenBalance({ code: 'USDC', issuer: 'GA5ZSE…KZVN' }); // "0" if no trustline
// Trustline (the trustline reserve is sponsored). Needs status 'ready'.
await wallet.addTrustline({ code: 'USDC', issuer: 'GA5ZSE…KZVN' });
// Soroban call. This account signs only its own require_auth entry. Needs status 'ready'.
await wallet.invokeContract({ contractId: 'C…', method: 'approve_milestone', args: [0, wallet.address] });
// Your backend built the transaction: sign the unsigned XDR, do not submit.
const signedXdr = await wallet.signXdr(unsignedXdr);
// Off-chain message signature. Works while undeployed.
const { signature, publicKey, curve } = await wallet.signMessage('hello'); // curve: 'ed25519'
Circle USDC on mainnet is USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN.
To let users pay reserves and fees in USDC instead of sponsored XLM, use @cavos/reserve and sign
with wallet.signXdr. See https://docs.cavos.xyz/docs/reserve.
Gotchas
appSalt,appId, anduserIdare permanent. Changing any of them silently moves every user to a new, empty wallet. Nothing errors.- A sponsored account holds 0 XLM. An XLM payment needs XLM in the account first. Trustlines and Soroban calls do not, because the sponsor pays their reserves and fees.
- Trustline-first apps call
create()first.addTrustlineandinvokeContractneedstatus === 'ready'. - Do not gate execution on
isReady.undeployedcan execute; onlyneeds-device-approvalblocks. - Second browser or device: the user lands on
needs-device-approvaluntil the device restores the MasterDEK (enclave sign-in, orapproveDeviceWithPasskey()if a passkey was added). redirect_uri is not registered: the exact origin is missing from Callback URLs. An empty list rejects everything.signMessageadds a Cavos domain prefix before signing. It is not a SEP-53 signature. Verify withKeypair.fromPublicKey(publicKey).verify(prefixedBytes, signature).- Mainnet needs gas. Fund the app's Stellar gas in the dashboard, or sponsored calls fail.
Version note
Written for @cavos/kit 0.5.3 (create() is new in 0.5.3). Verified on Stellar mainnet on 2026-11-12: account
GCTAQX…P5LG4Q,
created by the Cavos relayer, with a sponsored Circle USDC trustline.