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.
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
| 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 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:
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 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:
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.
| Factor | Native DEK | Grandfathered |
|---|---|---|
| Device | Ed25519 imported from the DEK | Random WebCrypto key; first device's pubkey is the G… |
| Passkey | Encrypted copy of the MasterDEK (can spend) | Extra weight-1 signer |
| Enclave | Seals / unwraps the DEK | Not used |
| Recovery code | Not used | Extra 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().
Add a passkey (recommended)
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.
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:
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:
import { generateRecoveryCode } from "@cavos/kit";
const code = generateRecoveryCode();
await wallet.setupRecovery(code);
// show `code` once; the kit never persists it.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:
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.
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
| 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 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:
- Registry lookup:
(userId, appId, chain, network). - If found: that
G…is returned. Native: unwrap the MasterDEK or land onneeds-device-approval. Grandfathered: check whether this device already holds a Horizon signer. - If not found: native mints a random MasterDEK and claims the
HKDF
G…. Grandfathered mints a random control key whose pubkey is theG…. 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.
const wallet = session.wallet("stellar");
console.log(wallet.address); // G… address
console.log(wallet.status); // "undeployed" until first executeNetworks & 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. 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 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. - ✅ 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.
Solana
Use @cavos/kit on Solana — a native Ed25519 system account whose spend key is HKDF of a MasterDEK. The account pays its own fee, your relayer pays it, or the user pays in a token they hold.
Reserve
Pay Stellar reserves and fees in a held token with @cavos/reserve. Classic G… accounts. Any wallet.