# API reference (/docs/api-reference) Everything below is exported from the package root: `import { ... } from "@cavos/kit"`. ## `Cavos` [#cavos] The high-level entry point. Most apps only use this. ### `Cavos.connect(options): Promise` [#cavosconnectoptions-promisecavoswallet] Unified entry point. Pick a `chain` and a `network` environment; the kit resolves the concrete network (sepolia / solana-devnet for testnet, mainnet otherwise) and returns a chain-native wallet. The result is a discriminated union (`wallet.chain`), so `execute()` keeps each chain's native signature. ```ts title="TypeScript" interface ConnectOptions { chain: "starknet" | "solana" | "stellar"; // returned wallet is discriminated by this network: "testnet" | "mainnet"; // resolves to the chain's concrete network appSalt: string; auth?: AuthProvider; // pass `auth` OR `identity` identity?: Identity; appId?: string; // hosted registry + (Starknet) recovery / (Solana, Stellar) relayer backendUrl?: string; // defaults to Cavos hosted services rpcUrl?: string; registry?: WalletRegistry; // off-chain userId -> wallet map recovery?: RecoveryClient; // device-approval relay (Starknet) createSigner?: (keyId: string) => Promise; // native / tests // --- Starknet-only --- paymasterApiKey?: string; // sponsors deploy + execute (required on Starknet) paymasterUrl?: string; classHash?: string; // --- Solana-only --- programId?: string; // device-account program override relayer?: SolanaRelayer; // gasless sponsorship (defaults to hosted when appId set) feePayer?: Keypair; // self-funded fallback // --- Stellar-only (classic `G…` multisig) --- stellarRelayer?: StellarRelayer; // gasless sponsorship (defaults to hosted when appId set) stellarSourceKeypair?: StellarKeypair;// self-funded source/fee-payer (advanced) stellarDeviceKey?: DeviceUnwrapKey; // this device's ECDH unwrap key (defaults to persisted WebCrypto) } type CavosWallet = Cavos | CavosSolana | CavosStellar; // narrow on `wallet.chain` ``` * `chain === "starknet"` → `Cavos` instance (see [Starknet](/docs/starknet)). * `chain === "solana"` → `CavosSolana` instance (see [Solana](/docs/solana)). * `chain === "stellar"` → `CavosStellar` instance (classic `G…` multisig — see [Stellar](/docs/stellar)). ### Instance members (Starknet) [#instance-members-starknet] Members on the `Cavos` instance (`wallet.chain === "starknet"`). The Solana counterpart is `CavosSolana` — see [Solana](/docs/solana). | Member | Description | | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `cavos.address: string` | The smart account address (deterministic). | | `cavos.status: "ready" \| "needs-device-approval"` | Whether this device can operate the wallet. | | `cavos.pendingRequestId: string \| null` | Device-addition request id when `status` is `needs-device-approval`. | | `cavos.identity: Identity` | The authenticated identity. | | `cavos.publicKey: DevicePublicKey` | This device's public key. | | `cavos.execute(calls): Promise<{ transactionHash }>` | Sign + submit calls, gasless. Requires `status === "ready"`. | | `cavos.signMessage(message): Promise` | Sign an arbitrary message off-chain with the device key (secp256r1). Uniform return across chains. See [After sign-in](/docs/post-login). | | `cavos.signTransaction(calls): Promise` | Build + sign a multicall WITHOUT submitting. Single-use (binds to nonce + resource bounds). | | `cavos.addSigner(pubkey): Promise<{ transactionHash }>` | Authorize another device's key on-chain. | | `cavos.enrollPasskey(passkey, params): Promise<{ publicKey, transactionHash? }>` | Create a passkey + register it as an approver. Gasless, idempotent. See [Passkeys](/docs/passkeys). | | `cavos.addApprover(pubkey): Promise<{ transactionHash? }>` | Register an already-enrolled passkey pubkey as an approver (idempotent). Use to share one passkey across chains. | | `cavos.approveThisDeviceWithPasskey({ passkey, submit? }): Promise<{ transactionHash }>` | From a `needs-device-approval` browser, use a synced passkey to authorize this device. | | `cavos.passkeyLeafForThisDevice(): Promise<{ leaf, nonce }>` | This device's passkey leaf + on-chain nonce (for batched / multi-chain approval). | | `cavos.submitPasskeyApproval(assertion, leaves, leafIndex, nonce, submit?): Promise<{ transactionHash }>` | Submit `add_signer_via_passkey` with a shared assertion. Low-level; prefer `approveThisDeviceWithPasskey`. | | `cavos.setupRecovery(code): Promise<{ transactionHash } \| undefined>` | Register a passphrase-derived backup signer. Idempotent. (Starknet only.) | ### `Cavos.recover(options): Promise` [#cavosrecoveroptions-promisecavos] Recover after losing every device using a saved recovery code. (Starknet path; Solana uses `CavosSolana.recover`. On Stellar you reconnect on the fresh device and call `wallet.approveThisDeviceWithRecovery(code)` — the recovery code unlocks the control key, which authorizes the new device's slot.) ```ts title="TypeScript" interface RecoveryOptions { code: string; identity: Identity; network: "testnet" | "mainnet"; appSalt: string; paymasterApiKey: string; appId?: string; backendUrl?: string; rpcUrl?: string; paymasterUrl?: string; classHash?: string; registry?: WalletRegistry; createSigner?: (keyId: string) => Promise; } ``` ## Authentication [#authentication] | Export | Description | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `CavosAuth` | Hosted login: `getGoogleOAuthUrl`, `getAppleOAuthUrl`, `handleCallback`, `sendOtp`, `verifyOtp`, `sendMagicLink`, `authenticate`. | | `StaticIdentity` | Wrap a `{ userId, email }` you already have as an `AuthProvider`. | | `AuthProvider`, `Identity` | Types for the auth contract. | | `deriveAddressSeed(input)` | Poseidon hash of `{ userId, appSalt }` → `address_seed`. | ## Recovery & multi-device [#recovery--multi-device] | Export | Description | | ---------------------------------------------- | ----------------------------------------------------------- | | `RecoveryClient`, `PendingDeviceRequest` | Interface for the non-custodial device-approval relay. | | `HttpRecoveryClient` | HTTP implementation talking to the Cavos backend. | | `WalletRegistry`, `RegisteredWallet` | Off-chain `userId` → wallet map (cross-device recognition). | | `InMemoryWalletRegistry`, `HttpWalletRegistry` | Registry implementations. | | `BackupSigner` | Passphrase-derived signer (`DeviceSigner`). | | `generateRecoveryCode()` | New high-entropy recovery code. | | `deriveBackupKey(code)` | Deterministic keypair from a recovery code. | ## Passkeys [#passkeys] A second factor for approving new devices — not a transaction signer. See [Passkeys](/docs/passkeys). | Export | Description | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `PasskeySigner` | Browser WebAuthn signer. `static isSupported()`, `enroll({ userId, userName, displayName? })` (creates a synced discoverable credential), `assert(challenge)` (produces a `PasskeyAssertion`). | | `PasskeySignerOptions`, `PasskeyEnrollParams`, `EnrolledPasskey` | Types for passkey construction + enrollment. | | `PasskeyAssertion` | Parsed WebAuthn assertion (`authenticatorData`, `clientDataJSON`, `r`, `s`, `challengeOffset`). | | `approveDeviceEverywhere(wallets, passkey)` | Low-level/advanced: approve this device across multiple independently-managed chain wallets with a **single** passkey prompt. Only touches wallets whose `status === "needs-device-approval"`; submits per-chain in parallel with error isolation. Returns `{ chain, transactionHash?, error? }[]`. Most apps target one chain and use `approveThisDeviceWithPasskey` instead. | | `PasskeyApprovable` | Structural interface implemented by `Cavos` and `CavosSolana` — what `approveDeviceEverywhere` operates on. Classic Stellar is **not** part of the batch: its passkey factor is a WebAuthn-PRF secret, so approve a Stellar device with `CavosStellar.approveThisDeviceWithPasskey(prfOutput)` separately. | Passkey approval on **Starknet mainnet** is not yet available (the mainnet account class hasn't been re-declared with the `add_signer_via_passkey` surface). Use it on **Sepolia**, or on Solana / Stellar (relayer-routed). On Starknet mainnet, fall back to [multi-device](/docs/multi-device) / [recovery](/docs/recovery). ## Signers [#signers] | Export | Description | | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `WebCryptoSigner` | Browser silent device signer (non-extractable P-256 in IndexedDB). `loadOrCreate`, `create`, `load`. | | `PasskeySigner` | Browser WebAuthn passkey signer — see [Passkeys](/docs/passkeys). | | `DeviceSigner`, `DevicePublicKey`, `DeviceSignature` | Signer types (shared across chains). | ## Chain adapters & constants [#chain-adapters--constants] ### Starknet [#starknet] | Export | Description | | ------------------------------------------ | ------------------------------------------------------------------------------------- | | `StarknetAdapter` | Computes addresses, builds deploy/initialize/add/remove calls, serializes signatures. | | `STARKNET_NETWORKS`, `StarknetNetwork` | Network configs (`sepolia`, `mainnet`). | | `DEVICE_ACCOUNT_CLASS_HASH`, `UDC_ADDRESS` | Deployed class hash + Universal Deployer address. | | `StarknetDeviceSigner` | Drop-in starknet.js `SignerInterface` backed by a device signer. | ### Solana [#solana] | Export | Description | | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CavosSolana` | High-level Solana client — the analogue of `Cavos`. `connect`, `execute(amount, destination)` (SOL transfer), `executeInstructions(instructions)` (arbitrary CPI), `signMessage(message)` (off-chain, P-256), `signTransaction(amount, destination)` (device precompile signature, relayer-bound), `addSigner`, `setupRecovery`, static `recover`. Returned by `Cavos.connect({ chain: "solana" })`. | | `InstructionData`, `InstructionAccount` | Serializable instruction shape for `executeInstructions` (mirrors the on-chain Borsh layout). | | `SolanaRelayer`, `SolanaRelayerOptions` | Cavos gasless sponsoring relayer (co-signs as fee payer). Activated by `appId`. | | `SolanaAdapter`, `SolanaAdapterOptions` | Low-level adapter: PDA derivation, the `[secp256r1 precompile, program]` instruction builders, signature encoding. | | `compressedPubkey`, `encodeLowSSignature`, `buildSecp256r1Instruction`, `anchorDiscriminator` | Low-level Solana helpers. | | `SOLANA_NETWORKS`, `SolanaNetwork` | Network configs (`solana-devnet`, `solana-mainnet`, `solana-localnet`). | | `DEVICE_ACCOUNT_PROGRAM_ID`, `SECP256R1_PROGRAM_ID` | Cavos device-account program id + native secp256r1 precompile id. | | `deriveAddressSeedSolana` | SHA-256 `address_seed` from `{ userId, appSalt }` (Solana variant of `deriveAddressSeed`). | ### Stellar [#stellar] Classic `G…` multisig account (self-custodial, no backend/registry). Live on testnet and mainnet. | Export | Description | | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CavosStellar` | High-level Stellar client — the analogue of `Cavos`. `connect`, `execute(amount, destination)` (native XLM transfer, stroops), `signMessage(message)` (off-chain, **ed25519** control key), `signTransaction(amount, destination)` (signed inner XDR, not submitted), `balance()` (XLM, stroops), `enrollPasskey(prfOutput)` (add a WebAuthn-PRF passkey unlock factor), `setupRecovery(code)` (add a recovery-code unlock factor), `approveThisDeviceWithPasskey(prfOutput)`, `approveThisDeviceWithRecovery(code)`, `hasPasskey`, `isReady`, `controlAddress`. Returned by `Cavos.connect({ chain: "stellar" })`. | | `StellarAdapter`, `StellarAdapterOptions` | Low-level adapter over Horizon: deterministic master-key derivation, account creation/payment/data-entry transaction builders, fee-bump wrapping, submit. | | `StellarRelayer`, `StellarRelayerOptions`, `StellarRelayKind` | Cavos gasless sponsoring relayer (signs/submits/fee-bumps via a relayer source). Activated by `appId`. | | `ConnectStellarOptions`, `StellarConnectStatus` | Option/status types for `CavosStellar.connect`. | | `STELLAR_NETWORKS`, `StellarNetwork`, `HORIZON_URL`, `XLM_DECIMALS` | Network configs + Horizon endpoints (`stellar-testnet`, `stellar-mainnet`). | | `DeviceUnwrapKey`, `LocalDeviceUnwrapKey`, `WebCryptoDeviceUnwrapKey`, `WebCryptoUnwrapKeyOptions`, `deviceSlotId` | This device's ECDH key that unwraps the account's control-key envelope (defaults to a persisted WebCrypto key in the browser). | | `PasskeyPrf`, `PasskeyPrfOptions`, `PasskeyPrfEnrollParams` | WebAuthn **PRF** helper: a synced passkey derives a stable secret that unlocks the control key (Stellar's passkey factor is a PRF secret, not an on-chain assertion). | | `deriveStellarAddress`, `deriveStellarMasterKeypair`, `generateControlKey` | Deterministic master-address/keypair derivation + control-key generation. | | `generateDEK`, `sealControlSeed`, `openControlSeed`, `wrapDEK`, `unwrapDEK`, `eciesWrapDEK`, `eciesUnwrapDEK`, `derivePasskeyKEK`, `deriveRecoveryKEK` | Envelope crypto: seal the control key under a DEK and wrap the DEK per unlock factor (device / passkey / recovery). | | `toDataEntries`, `fromDataEntries`, `AccountEnvelope` | Serialize the on-chain control-key envelope to/from account data entries. | | `deriveAddressSeedStellar` | SHA-256 `address_seed` from `{ userId, appSalt }` (Stellar variant of `deriveAddressSeed`). | ### Shared interface types [#shared-interface-types] | Export | Description | | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `ChainAdapter`, `ChainCall`, `ComputeAddressParams` | Multi-chain interface types. | | `Chain` (`"starknet" \| "solana" \| "stellar"`), `NetworkEnv` (`"testnet" \| "mainnet"`), `CavosWallet` | Unified connect types. | | `MessageSignature` | `{ signature: Uint8Array; publicKey: string; curve: "secp256r1" \| "ed25519" }` — off-chain message signature. See [After sign-in](/docs/post-login). | | `SignedTransaction`, `StarknetSignedTransaction`, `StellarSignedTransaction`, `SolanaSignedTransaction` | Sign-without-submit result, chain-specific (narrow on `chain`). | | `SignatureCurve` | `"secp256r1" \| "ed25519"` — which curve a `MessageSignature` was produced with. | | `CAVOS_MESSAGE_PREFIX`, `prefixedMessageBytes(message)` | The off-chain message prefix scheme (`"Cavos Signed Message:\n\n"`). | ## React (`@cavos/kit/react`) [#react-cavoskitreact] React bindings shipped in the same package under the `/react` subpath. See [React](/docs/react) for the full guide. | Export | Description | | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `CavosProvider` | Wrap your app once; manages one chain's wallet + mounts the auth modal. Props: `config: CavosConfig`, `modal?: CavosModalConfig`, `children`. | | `useCavos()` | Primary hook — full wallet surface: state (`address`, `wallet`, `chain`, `walletStatus`, `user`, `isAuthenticated`), login, `execute` (Starknet), device/recovery wrappers, `logout`. | | `useCavosAuth()` | Thin subset for components that only open the modal and read auth state (`openModal`, `closeModal`, `isAuthenticated`, `address`, `user`, `walletStatus`, `logout`). | | `CavosAuthModal` | Customizable login modal. Renders as a bottom sheet automatically on mobile (`max-width: 640px`). | | `CavosConfig`, `CavosModalConfig`, `CavosAuthModalProps`, `CavosProviderProps`, `CavosContextValue`, `UserInfo`, `WalletStatus` | Type-only exports for the React layer. | `CavosAuthModalProps` / `CavosModalConfig` include theming (`theme`, `backgroundColor`, `primaryColor`, `radius`), branding (`appName`, `appLogo`, `appLogoSize`), `providers`, `emailMode`, `secureStep`, and `onSuccess`. `Chain` and `NetworkEnv` are exported from the **core** entry (`@cavos/kit`), not from `@cavos/kit/react`. Import them from `import type { Chain } from "@cavos/kit"`. ## Low-level crypto (advanced) [#low-level-crypto-advanced] | Export | Description | | ----------------------------------------------------------------------------- | ----------------------------------------------- | | `signatureToFelts`, `recoverYParity` | secp256r1 signature ↔ 5-felt Starknet encoding. | | `u256ToFelts`, `bytesToBigInt`, `bytesToHex`, `hexToBytes`, `bigIntTo32Bytes` | Encoding helpers. | # Authentication (/docs/auth) Authentication exists for **one reason**: to produce a stable `userId` that, with your `appSalt`, derives the wallet address. The login **never signs transactions** — that is always the device key. You have two options: bring your own identity, or use Cavos hosted auth. ## Option A — Bring your own identity [#option-a--bring-your-own-identity] If you already authenticate users (your own auth, Clerk, Auth0, etc.), just pass a stable `userId`: ```ts title="TypeScript" const cavos = await Cavos.connect({ chain: "starknet", network: "testnet", appSalt: "my-app", identity: { userId: user.id, email: user.email }, appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, }); ``` `userId` must be **stable and unique per user**. If it changes, the derived address changes and the user gets a different wallet. Use an immutable primary key, not an email that can change. ## Option B — Cavos hosted auth (`CavosAuth`) [#option-b--cavos-hosted-auth-cavosauth] `CavosAuth` provides Google, Apple, and email OTP / magic-link login that resolves to a Cavos `Identity` you pass straight into `connect`. ```ts title="TypeScript" import { CavosAuth } from "@cavos/kit"; const auth = new CavosAuth({ appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, // backendUrl defaults to the Cavos hosted services }); ``` ### OAuth (Google / Apple) [#oauth-google--apple] OAuth is multi-step (redirect out, handle the callback), so resolve the identity first, then call `connect` with it. ```ts title="TypeScript" // 1. Redirect the user to the provider const url = await auth.getGoogleOAuthUrl(/* redirectUri? */); window.location.href = url; // or auth.getAppleOAuthUrl() // 2. On your callback route, exchange the result for an identity const identity = await auth.handleCallback(window.location.search); // 3. Connect with the resolved identity const cavos = await Cavos.connect({ chain: "starknet", network: "testnet", appSalt: "my-app", identity, appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, }); ``` ### Email OTP [#email-otp] ```ts title="TypeScript" await auth.sendOtp(email); const identity = await auth.verifyOtp(email, code); const cavos = await Cavos.connect({ chain: "starknet", network: "testnet", appSalt: "my-app", identity, appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, }); ``` `sendMagicLink(email)` is also available as an alternative to OTP. ## How the address is derived [#how-the-address-is-derived] Internally, `connect` calls `deriveAddressSeed({ userId, appSalt })` (a Poseidon hash) to get a stable `address_seed`, then the Starknet adapter computes the counterfactual contract address from that seed. The address depends **only** on identity + salt — never on the device key — so the same user always lands on the same wallet, and you can compute it before deployment. ```ts title="TypeScript" import { deriveAddressSeed, StarknetAdapter, DEVICE_ACCOUNT_CLASS_HASH } from "@cavos/kit"; const addressSeed = deriveAddressSeed({ userId: user.id, appSalt: "my-app" }); const address = new StarknetAdapter({ classHash: DEVICE_ACCOUNT_CLASS_HASH.sepolia, }).computeAddress({ addressSeed }); ``` # Chains (/docs/chains) Cavos is built **chain-agnostic**. The model — an identity-derived address controlled by a silent device key, verified on-chain — isn't specific to any one network. Your application code targets the same `Cavos.connect` entry point regardless of chain; only the `chain` and `network` you pass change. ## Status [#status] | Chain | Status | Networks | | ------------ | ----------- | ------------------------------------ | | **Starknet** | ✅ Available | `sepolia`, `mainnet` | | **Solana** | ✅ Available | `solana-devnet`, `solana-mainnet` | | **Stellar** | ✅ Available | `stellar-testnet`, `stellar-mainnet` | **All three chains are production-ready.** Starknet is proven on Sepolia; Solana ships a full device-account program, gasless relayer, and e2e coverage. Stellar runs a classic `G…` multisig account with the `CavosStellar` client and gasless relayer, live on testnet and mainnet. See [Stellar](/docs/stellar). ## Per-chain guides [#per-chain-guides] Each implemented chain has its own page with a quickstart and the chain-specific details: ## How the abstraction works [#how-the-abstraction-works] Two layers keep Cavos portable: * **The device signer is the constant.** A non-extractable secp256r1 (P-256) key on the device is the user's authority on every chain. Identity → `address_seed` derivation is the same everywhere. * **A `ChainAdapter` per chain** handles the chain-specific parts: deterministic address computation, signature encoding, and the deploy / add-signer / remove-signer calls. `StarknetAdapter`, `SolanaAdapter`, and `StellarAdapter` ship today. Curves differ across ecosystems, and each adapter resolves that: * **Starknet** — on-chain secp256r1 verification in the Cairo `DeviceAccount`. * **Solana** — every guarded action pairs Solana's **native secp256r1 precompile** with the Cavos device-account program instruction. * **Stellar** — a **classic `G…` multisig account** (no Soroban contract): a deterministic master key with weight 0 plus a control key sealed on-chain in the account's data entries. The control key is unwrapped locally per-device (via an ECDH device key, a WebAuthn PRF passkey, or a recovery code) and signs transactions. Self-custodial, no backend key. ## Writing chain-agnostic code [#writing-chain-agnostic-code] Target the unified `Cavos.connect` with `{ chain, network }`, and narrow the returned wallet on `wallet.chain` before calling chain-native methods: ```ts title="TypeScript" import { Cavos } from "@cavos/kit"; // One entry point; chain + network are the only chain-specific inputs. const wallet = await Cavos.connect({ chain: "starknet", // "starknet" | "solana" | "stellar" network: "testnet", // resolves to sepolia / solana-devnet / stellar-testnet identity: { userId, email }, appSalt: "my-app", appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, // Starknet-only: paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, }); // execute() keeps each chain's native signature — narrow on `chain`: if (wallet.chain === "starknet") { await wallet.execute(calls); // arbitrary Starknet calls } else if (wallet.chain === "solana") { await wallet.execute(amount, destination); // lamport transfer } else if (wallet.chain === "stellar") { await wallet.execute(amount, destination); // XLM transfer (stroops) } ``` When a new chain lands, you pass its `chain` + `network` values — the rest of your integration (login, signing) stays identical. See [Concepts → multi-chain by design](/docs/concepts) for the architecture rationale. # Concepts (/docs/concepts) ## Non-custodial by construction [#non-custodial-by-construction] Cavos is non-custodial because **authority lives in the account contract, not the backend**. The Cairo `DeviceAccount` gates `add_signer` / `remove_signer` so they only succeed when called by the account itself — which only happens inside a transaction whose signature was validated against an existing, authorized device signer. Consequences: * The Cavos backend holds **no key material** and has **no privileged role** on any account. A full backend compromise cannot add a signer to any wallet. * Every signer change is authorized by a key the **user** holds (a device key or a passphrase-derived backup key), verified on-chain. * Backend services (registry, recovery relay) only **move messages** — they request, notify, and relay. They never sign. This is the single invariant to remember: **the contract is the sole authority over its signers.** ## Deterministic addresses [#deterministic-addresses] The wallet address is `f(identity, appSalt)` via `deriveAddressSeed` (Poseidon) → counterfactual contract address. It depends only on identity and salt, **never on the device key**, so: * The same user always lands on the same wallet. * You can compute the address before the account is deployed. * A new device on an existing account is recognized, not forked into a second wallet. The account starts with zero signers; the first device key is registered via a one-time `initialize` submitted atomically with deployment, which prevents front-running of the first signer. ## Silent signing trade-off [#silent-signing-trade-off] Device keys sign silently — no per-signature user-verification prompt. The key is non-extractable and device-bound, so it is non-custodial and on-chain verified. This is the embedded-wallet model used by Privy and Turnkey: maximum UX, no friction per transaction. The user-verification step lives on **device management**, not signing. An optional [passkey](/docs/passkeys) acts as a second factor for approving *new* devices; it never gates individual transactions. Together with [multi-device](/docs/multi-device) and [recovery](/docs/recovery), that covers device loss without compromising the silent-signing UX. ## Networks & constants [#networks--constants] ```ts title="TypeScript" import { STARKNET_NETWORKS, DEVICE_ACCOUNT_CLASS_HASH } from "@cavos/kit"; STARKNET_NETWORKS.sepolia; // RPC + chain config DEVICE_ACCOUNT_CLASS_HASH.sepolia; // deployed DeviceAccount class hash ``` `Cavos.connect` takes `network: "testnet" | "mainnet"` and resolves it to the chain's concrete network (Starknet testnet → `sepolia`). The `STARKNET_NETWORKS` / `DEVICE_ACCOUNT_CLASS_HASH` maps are still keyed by the concrete Starknet network (`sepolia`, `mainnet`); the kit ships the current class-hash values. ## Multi-chain by design [#multi-chain-by-design] The kit is structured around a `ChainAdapter` interface, with `StarknetAdapter`, `SolanaAdapter`, and `StellarAdapter` implemented today (all three live on testnet and mainnet). New chains slot in behind the same `Cavos.connect` API surface, so application code written against `Cavos.connect` stays stable as chains are added. Each chain keeps its native `execute` signature (narrow the returned wallet on `wallet.chain`); see [Chains](/docs/chains) for the per-chain guides. # For agents & LLMs (/docs/for-agents) These docs are built to be **consumed by AI agents**. If you're an LLM reading this, or a developer handing this to one, here's the fast path. ## Plain-text endpoints [#plain-text-endpoints] | URL | Contents | | ---------------------------------- | ------------------------------------------------------------------------------------------ | | [`/llms.txt`](/llms.txt) | Index of every page with descriptions — a map of the docs. | | [`/llms-full.txt`](/llms-full.txt) | **The entire documentation as one plain-text file.** Paste this into your model's context. | | `/llms.mdx/docs//content.md` | Any page's raw Markdown, e.g. `/llms.mdx/docs/quickstart/content.md`. | The quickest way to give an assistant everything it needs: ```bash title="Terminal" curl https://docs.cavos.xyz/llms-full.txt ``` Then ask it to integrate `@cavos/kit` into your app. ## A prompt that works [#a-prompt-that-works] > Using the Cavos docs below, add `@cavos/kit` to my Next.js app: a login button > that calls `Cavos.connect` (Sepolia), shows the wallet address, and a button > that runs a gasless `cavos.execute`. Set up passphrase recovery too. > > \[paste contents of /llms-full.txt] ## What an agent should know up front [#what-an-agent-should-know-up-front] * **Install:** `npm install @cavos/kit`. * **Three chains, all production-ready:** **Starknet**, **Solana**, and **Stellar** (classic `G…` multisig) are live on testnet and mainnet. See [Chains](/docs/chains). * **Passkeys are a 2FA step-up, not a signer.** After signup, prompt the user for a passkey (`cavos.enrollPasskey`); it approves *new devices* on any browser — it never signs transactions. Use `approveThisDeviceWithPasskey` / `approveDeviceEverywhere` to self-approve on a fresh device. Caveat: passkey approval on **Starknet mainnet** isn't available yet (Sepolia only). See [Passkeys](/docs/passkeys). * **One unified entry point:** `Cavos.connect({ chain, network, identity | auth, appSalt, appId, ... })`. The returned wallet is a discriminated union — narrow on `wallet.chain` before calling `execute`. * **`execute` differs per chain:** Starknet → `wallet.execute(calls)` (arbitrary calls); Solana → `wallet.execute(amount, destination)` for SOL transfers, or `wallet.executeInstructions(instructions)` for arbitrary CPI (SPL transfers, swaps) gated by the app's program allowlist; Stellar → `wallet.execute(amount, destination)` for XLM (stroops), plus `wallet.balance()`. Stellar is a classic `G…` multisig account (not Soroban); no arbitrary-call surface today. * **Gas sponsorship:** Starknet needs `paymasterApiKey`; Solana is gasless via the relayer activated by `appId` (no `paymasterApiKey`). Solana arbitrary execute is allowlisted per-app (dashboard → Solana Programs). * **Gate execution on status:** only call `execute` when `status === "ready"`; handle `"needs-device-approval"` via [multi-device](/docs/multi-device). * **Secrets:** `appId` is public (`NEXT_PUBLIC_`); `paymasterApiKey` is server-side. * **Stable `userId`:** the address is derived from it — never let it change for a user. * **Recovery works on all chains:** call `wallet.setupRecovery(generateRecoveryCode())` once on a ready device and have the user store the code. Restore after losing every device via `Cavos.recover` (Starknet), `CavosSolana.recover` (Solana), or — on Stellar — reconnect on the new device and call `wallet.approveThisDeviceWithRecovery(code)` (the code unlocks the control key that authorizes the new device). * **Non-custodial invariant:** the on-chain account is the sole authority over signers; the backend never signs. Don't design flows that assume Cavos can add a signer server-side. ## Reference pages [#reference-pages] # Overview (/docs) `@cavos/kit` gives your users a **self-custodial smart account** that they control with a **silent device key** — no seed phrase, no MPC, no browser extension, no popups. A login (OAuth or email) only derives the wallet address; the actual signing happens invisibly with a non-extractable secp256r1 (P-256) key that lives on the device. The model is **chain-agnostic** — the same API works across high-performance chains. **Starknet, Solana, and Stellar are available today.** See [Chains](/docs/chains). ```ts title="TypeScript" import { Cavos } from "@cavos/kit"; const cavos = await Cavos.connect({ chain: "starknet", // "starknet" | "solana" | "stellar" network: "testnet", identity: { userId: user.id, email: user.email }, appSalt: "my-app", appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, }); if (cavos.status === "ready") { await cavos.execute(calls); // gasless; signed silently by the device key } ``` ## Mental model [#mental-model] | Piece | Role | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Identity** | A stable `userId` (from your login) + an `appSalt`. Together they derive the wallet address — device-independent. | | **Device signer** | A non-extractable secp256r1 key on the device (WebCrypto in the browser). Signs `sha256(tx_hash)` with no UI. | | **Account contract** | The on-chain Cavos account for the target chain (a Cairo `DeviceAccount` on Starknet today). Validates device signatures on-chain and is the sole authority over its signers. | | **Chain adapter** | The per-chain layer (address derivation, signature encoding, deploy calls). `StarknetAdapter`, `SolanaAdapter`, and `StellarAdapter` today. See [Chains](/docs/chains). | | **Paymaster** | Sponsors deploy + execute so transactions are gasless for the user. | ## What makes it different [#what-makes-it-different] * **No seed phrases.** The user never sees or manages a private key. * **MPC-free.** No key sharding, no signing servers. The key is whole and on the device. * **Verifiable.** Signatures are checked on-chain by the account contract, not by a backend. * **Deterministic address.** Same identity → same wallet address, every time, before deployment. * **Gasless.** Deploy and execution are sponsored through the Cavos paymaster. * **Multi-device + passkey 2FA + passphrase recovery.** Three non-custodial ways to never lose access. ## Where to next [#where-to-next] # Multi-device (/docs/multi-device) A wallet's address depends only on identity, so the **same user on a new device lands on the same wallet**. But that new device has its own key, which isn't yet an authorized signer on the account. Adding it requires approval from a device that already controls the wallet — the contract is the sole authority. ## Detecting a new device [#detecting-a-new-device] When `Cavos.connect` runs on a device whose key isn't an authorized signer, it returns `status: "needs-device-approval"` and records a pending request: ```ts title="TypeScript" const cavos = await Cavos.connect({ /* ... */ }); if (cavos.status === "needs-device-approval") { // This device requested addition. Prompt the user to approve from an // existing device. Poll for approval, then reconnect. console.log(cavos.pendingRequestId); } ``` Under the hood, when an `appId` is set, the kit uses the hosted `HttpRecoveryClient` to file a device-addition request, which notifies the user (styled as a login-approval prompt). ## Approve with a passkey instead [#approve-with-a-passkey-instead] If the user [enrolled a passkey](/docs/passkeys) after signup, they can self-approve from the new browser with **one prompt** — no email round-trip, no second device: ```ts title="TypeScript" import { Cavos, PasskeySigner } from "@cavos/kit"; const cavos = await Cavos.connect({ /* same identity + appSalt... */ }); if (cavos.status === "needs-device-approval") { await cavos.approveThisDeviceWithPasskey({ passkey: new PasskeySigner() }); // cavos is now ready. } ``` `approveThisDeviceWithPasskey` is the recommended path for users who have a passkey; the hosted approval page below is the fallback for those who don't. **Starknet mainnet caveat.** Passkey approval currently works on **Sepolia** only. On Starknet mainnet, fall back to the hosted approval page (or [recovery](/docs/recovery)). Solana and Stellar route passkey approval through their relayers and are not affected. ## Host your approval page (required) [#host-your-approval-page-required] **Cavos does not host an approval page.** Each integrating app must build its own `/approve-device` route and configure its origin in the dashboard. Approving a device signs an on-chain `add_signer` transaction paid for with **the app's own paymaster API key** — Cavos never sponsors gas for third-party apps. When a device-addition request is filed, the backend builds the approval link as `${device_approval_url}/approve-device?request=` and emails it to the owner. Set `device_approval_url` in your app's dashboard (or `website_url` as a fallback) — if neither is configured, device addition is rejected. The approving page you build does three things, on a device that is **already an authorized signer**: 1. **Fetch** the pending request to read the new device's public key. 2. **Approve** it by calling `cavos.addSigner(newSigner)` — this submits the on-chain `add_signer` call, gasless via your paymaster key. 3. **Confirm** it with the recovery client so the backend marks the request approved. Your page must construct its `CavosConfig` with the **same** `appSalt`, `appId`, and `network` the wallet was created under (the backend returns them in the request response), and its own `paymasterApiKey`. ```ts title="app/approve-device/page.tsx" 'use client'; import { useEffect, useState } from 'react'; import { CavosProvider, useCavos, HttpRecoveryClient, type CavosConfig, } from '@cavos/kit'; const BACKEND_URL = 'https://services.cavos.xyz'; function Approve({ request }: { request: any }) { const { isAuthenticated, openModal, addSigner } = useCavos(); const [done, setDone] = useState(false); const approve = async () => { // Signs add_signer(newSigner) gaslessly with THIS (registered) device's key. const { transactionHash } = await addSigner(request.newSigner); const recovery = new HttpRecoveryClient({ baseUrl: BACKEND_URL, appId: request.appId, }); await recovery.confirmDeviceAddition({ requestId: request.requestId, txHash: transactionHash }); setDone(true); }; if (done) return

Device approved.

; if (!isAuthenticated) return ; return ; } export default function ApproveDevicePage() { const [config, setConfig] = useState(null); const [request, setRequest] = useState(null); useEffect(() => { const requestId = new URLSearchParams(window.location.search).get('request')!; // The GET returns appSalt, appId, network + the new device's pubkey, // so this page can rebuild the SAME wallet identity the owner registered. fetch(`${BACKEND_URL}/api/devices/request?id=${requestId}`) .then((r) => r.json()) .then((data) => { if (!data.found) return; setRequest({ requestId: data.request_id, appId: data.app_id, newSigner: { x: BigInt(data.new_pub_x), y: BigInt(data.new_pub_y) }, }); setConfig({ // 'mainnet' or 'testnet' — map from the wallet's network. network: data.network === 'mainnet' ? 'mainnet' : 'testnet', appSalt: data.app_salt, appId: data.app_id, // YOUR paymaster key — never Cavos's. paymasterApiKey: process.env.NEXT_PUBLIC_CAVOS_PAYMASTER_API_KEY!, authBackendUrl: BACKEND_URL, }); }); }, []); if (!config || !request) return

Loading…

; return ( ); } ``` The approval page is the only place where a registered device authorizes a new one. Deploy it at a URL you control and set that URL as the app's `device_approval_url` in the Cavos dashboard. ## The non-custodial guarantee [#the-non-custodial-guarantee] The backend can **request, notify, and relay** a device addition — it can never **authorize** one. The account contract gates `add_signer` so it only succeeds when called by the account itself, which requires a valid signature from an existing on-chain signer. The backend holds no keys and has no privileged contract role. The `newSigner` pubkey in a pending request is only a **proposal** for the UI. The approving device's signature commits to the actual pubkey passed to `add_signer`, so tampering with the request produces an inert, unsigned record. Always show the user the device label and a pubkey fingerprint before they approve. **Encourage multiple devices early.** If a user only ever has one device and loses it, they fall back to [passphrase recovery](/docs/recovery). Adding a second device at onboarding makes recovery effortless. See [Concepts → non-custodial model](/docs/concepts) for the full security rationale. # Passkeys (2FA) (/docs/passkeys) Right after signup, walk the user through creating a **passkey**. It is a second factor for their wallet: it does **not** replace the silent device key and it **never signs transactions**. What it does is let them log in on a *new* device without having to find one that's already logged in. Treat it as **two-factor authentication** in your UI. Show the prompt immediately after the wallet is ready, framed as *"Turn on device approvals so you never get locked out"* — not as a signing step. That framing is what keeps the UX coherent: the user already authenticated and got a wallet, and this is the safety net on top. A passkey here is a **synced, discoverable WebAuthn credential** (iCloud Keychain / Google Password Manager) registered on-chain as an **approver**. It approves adding a new device's key — it does not authorize individual transactions. Signing stays silent, as described in [Signing & gasless](/docs/signing). ## Why a passkey [#why-a-passkey] Without a passkey, adding a new device requires **either** an already-registered device (the [multi-device](/docs/multi-device) approval flow) **or** the recovery passphrase ([recovery](/docs/recovery)). Both can fail the user at the worst moment: the other device isn't nearby, or the code was lost. A passkey removes that dependency. Because it syncs across the user's devices through the OS, a single WebAuthn prompt from **any** browser can authorize the new device's key on-chain — no second device hunt, no passphrase. It is the lowest-friction path to "log in on my new phone." ## Control when it's asked (React SDK) [#control-when-its-asked-react-sdk] If you use the `CavosProvider` with the built-in auth modal, the kit shows a **"Secure your account"** step automatically right after a brand-new wallet is created. You decide — per app — whether that step interrupts onboarding, runs as an optional nudge, or is skipped entirely. The `secureStep` modal option is the single knob for this: ```tsx title="app/layout.tsx" import { CavosProvider } from '@cavos/kit/react'; {children} ``` | `secureStep` | Behavior after a new account is created | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'optional'` *(default)* | Shows the "Secure your account" screen with a **Skip for now** button. Low friction — the user can defer passkey setup and still finish onboarding. | | `'required'` | Shows the screen **without** Skip. The user must set up a passkey (or save a [recovery passphrase](/docs/recovery)) before they can continue. Use this for high-value apps where a second factor is mandatory. | | `'off'` | Skips the screen entirely — onboarding ends the moment the wallet is ready. Choose this when you want to drive the 2FA prompt yourself later in the journey (e.g. a "Secure your account" banner on the dashboard), instead of interrupting signup. | ### Picking the right moment [#picking-the-right-moment] The right value depends on **where in the user journey** asking for a passkey feels natural, not disruptive: * **`'off'` — ask later.** Friction-free onboarding. Drop the user straight into the app, then surface the passkey prompt from your own UI once they've hit an "aha" moment (first transaction, settings page, or a dedicated security screen). You can still enroll any time with `enrollPasskey` from [`useCavos()`](/docs/multi-device) — `secureStep: 'off'` only removes the *automatic* post-signup screen. * **`'optional'` — ask now, let them skip.** The default. Good when you want to plant the seed early but respect the user's pace; the skip keeps conversion high while the prompt keeps the option visible. * **`'required'` — ask now, no skipping.** Best for custody-like UX, treasuries, or any context where losing device access is unacceptable. Pair it with the recovery passphrase fallback (the screen offers both) so the user always has a path forward even without a platform authenticator. The passkey setup is **gasless, idempotent, and device-signed** — the same call described in [Set it up after signup](#set-it-up-after-signup). `secureStep` only controls *whether and when* the built-in modal shows it automatically; the underlying `enrollPasskey` API is always available to trigger on your own terms. ## Set it up after signup [#set-it-up-after-signup] Once `connect` returns `status: "ready"`, gate on platform support and enroll. `enrollPasskey` does two things in one call: it creates the credential (a system prompt the user confirms with biometrics / screen lock) and registers its public key on-chain as an approver via `addApprover`. It is gasless, device-signed, and idempotent. ```ts title="TypeScript" import { Cavos, PasskeySigner } from "@cavos/kit"; // cavos is ready (status === "ready") right after signup. if (await PasskeySigner.isSupported()) { const passkey = new PasskeySigner(); // rpId defaults to your hostname const { publicKey } = await cavos.enrollPasskey(passkey, { userId: identity.userId, userName: identity.email ?? identity.userId, }); // publicKey { x, y } is now a registered approver of this wallet. } ``` Prompt for the passkey as a **2FA / "secure your account"** step right after the wallet is created — not during the auth flow and not as part of signing. The user should never associate this credential with approving individual transactions; it only ever appears when a *new device* is being added. Only the passkey's **public key** is registered on-chain. The credential itself is held and synced by the OS authenticator — the kit never stores it. ## Approve a new device with a passkey [#approve-a-new-device-with-a-passkey] When the same identity connects on a fresh browser, it lands on the same wallet address but with `status: "needs-device-approval"` — this device's key isn't an authorized signer yet. If the user enrolled a passkey, they can self-approve from that new browser in one prompt: ```ts title="TypeScript" const cavos = await Cavos.connect({ /* ...same identity + appSalt... */ }); if (cavos.status === "needs-device-approval") { const passkey = new PasskeySigner(); // One prompt: the synced passkey authorizes adding THIS device's key. await cavos.approveThisDeviceWithPasskey({ passkey }); // cavos is now ready; no second device required. } ``` Internally, the kit builds a per-chain "leaf" from the new device key and the on-chain passkey nonce, the passkey signs a challenge over it, and an `add_signer_via_passkey` call is submitted. The assertion does not carry the public key, so the kit recovers both secp256r1 candidates and matches the enrolled approver via an on-chain view — no backend lookup. **Advanced — multiple independent chains.** Each `Cavos` wallet handle is one chain. If your app manages several chain wallets for the same user, the low-level `approveDeviceEverywhere([wallet, ...], passkey)` util approves the device on each with a **single** passkey prompt (one batched challenge, isolated per-chain submission). It returns `{ chain, transactionHash?, error? }[]`, and reusing the same passkey pubkey across chains is a per-wallet `addApprover(pubkey)` (idempotent). This covers **Starknet and Solana** (the on-chain assertion model on this page). Most integrations target one chain and never need this. **Stellar passkeys work differently.** The classic `G…` account has no on-chain WebAuthn verifier. Instead, the passkey is a **WebAuthn PRF** credential whose derived secret unlocks the account's control key — enroll it with `stellarWallet.enrollPasskey(prfOutput)` and approve a new device with `stellarWallet.approveThisDeviceWithPasskey(prfOutput)`. It is **not** part of the `approveDeviceEverywhere` batch and does not use `addApprover` / `add_signer_via_passkey`. The React `CavosProvider` wraps this for you via `enrollPasskeyDefault()` / `approveDeviceWithPasskey()`. ## Prerequisites & limits [#prerequisites--limits] * **Secure context required.** WebAuthn needs HTTPS or `http://localhost`. A LAN IP fails — the kit throws an actionable error pointing you to `localhost`, a real HTTPS domain, or a tunnel (cloudflared / ngrok). The silent device key works over an IP; passkeys don't. * **Check support first.** Gate enrollment on `await PasskeySigner.isSupported()` (it checks for a platform authenticator). On unsupported platforms, fall back to the [multi-device](/docs/multi-device) and [recovery](/docs/recovery) flows. * **RP ID.** Defaults to `window.location.hostname`. Pass `rpId` explicitly only if you need a different registrable domain. * **Starknet mainnet caveat.** Passkey approval currently works on **Sepolia** only. The mainnet account class hasn't been re-declared with the `add_signer_via_passkey` surface yet, so on Starknet mainnet use [multi-device](/docs/multi-device) / [recovery](/docs/recovery) instead. Solana and Stellar route passkey approval through their relayers and are not affected. ## Non-custodial [#non-custodial] A passkey strengthens the wallet without introducing custody: * Only the passkey's **public key** is on-chain. The credential stays with the OS authenticator; Cavos never receives it. * `add_signer_via_passkey` is a public external that is authorized solely by the embedded WebAuthn assertion — it still requires a valid signature. Cavos sponsors the gas but **cannot** add a signer without the passkey's assertion. * Removing the passkey is an on-chain `remove_approver`, gated to the account like any signer change. Combine it with the other two fallbacks for the strongest setup: 1. **Passkey** (this page) — self-serve approval on any new device. 2. **Multiple devices** ([multi-device](/docs/multi-device)) — approve from an existing, logged-in device. 3. **Passphrase backup** ([recovery](/docs/recovery)) — the last resort when all devices and the passkey are gone. See [Concepts → non-custodial model](/docs/concepts) for the full security rationale. # After sign-in (/docs/post-login) Once `walletStatus.isReady` is true, the wallet is deployed and this device is an authorized signer. This page covers the common things you do next — all of it via `useCavos()` inside a ``. See [React](/docs/react) for the provider/hook setup. Every chain-specific call narrows on `wallet.chain` first, because `execute` keeps each chain's native signature. See [Chains](/docs/chains). ## Read the native balance [#read-the-native-balance] The kit exposes each chain's balance read differently. ### Solana [#solana] `CavosSolana` exposes the underlying `@solana/web3.js` `Connection`, so you read SOL directly. Balance is in **lamports** (1 SOL = 10⁹ lamports). ```ts title="TypeScript" const { wallet, address } = useCavos(); if (wallet?.chain === "solana") { const lamports = await wallet.connection.getBalance(address); // number const sol = lamports / 1e9; } ``` ### Stellar [#stellar] `CavosStellar` has a `balance()` method. It returns native XLM in **stroops** (1 XLM = 10⁷ stroops). ```ts title="TypeScript" if (wallet?.chain === "stellar") { const stroops = await wallet.balance(); // bigint const xlm = Number(stroops) / 1e7; } ``` ### Starknet [#starknet] There is no single "native balance" call in the kit for Starknet (the fee token is an ERC-20). Read a specific token with a `balanceOf` call via [execute](/docs/signing), or query the RPC directly with starknet.js. ## Fund from a testnet faucet [#fund-from-a-testnet-faucet] Only on `testnet`. Handy for demos and first-run UX. ### Solana — airdrop [#solana--airdrop] ```ts title="TypeScript" if (wallet?.chain === "solana") { const sig = await wallet.connection.requestAirdrop(address, 1_000_000_000); // 1 SOL // confirm with connection.getSignatureStatus(sig) if you need to wait } ``` Public Solana devnet rate-limits airdrops. For reliable funding, use a private RPC (Helius/Alchemy) as your `rpcUrl`, or the official Solana faucet. ### Stellar — friendbot [#stellar--friendbot] Stellar testnet is funded by the public friendbot: ```ts title="TypeScript" await fetch(`https://friendbot.stellar.org?addr=${encodeURIComponent(address)}`); ``` ## Send native tokens [#send-native-tokens] ### Solana — SOL transfer [#solana--sol-transfer] `CavosSolana.execute(amount, destination)` transfers SOL, sponsored by the relayer when `appId` is set. `amount` is in **lamports** (bigint). ```ts title="TypeScript" if (wallet?.chain === "solana") { const txHash = await wallet.execute(1_000_000_000n, destinationAddress); // 1 SOL } ``` ### Stellar — XLM transfer [#stellar--xlm-transfer] Same signature, `amount` in **stroops** (bigint). ```ts title="TypeScript" if (wallet?.chain === "stellar") { const txHash = await wallet.execute(10_000_0000n, destinationAddress); // 1 XLM } ``` Both return the transaction hash as a string. Build explorer links per chain — Solana: `https://explorer.solana.com/tx/${hash}?cluster=devnet`; Stellar testnet: `https://stellar.expert/explorer/testnet/tx/${hash}`. ### Starknet — arbitrary calls [#starknet--arbitrary-calls] On Starknet, use the context's `execute(calls)` for a multicall (transfers, swaps, any contract interaction). It's sponsored by default. ```ts title="TypeScript" const { execute } = useCavos(); const { transactionHash } = await execute([ { contractAddress: TOKEN, entrypoint: "transfer", calldata: [to, amountLow, amountHigh] }, ]); ``` Formatting amounts? Parse a human input (`"1.5"`) to base units by multiplying by `10 ** decimals` and rounding; format a base-unit bigint back by dividing. Decimals per chain: Solana 9, Stellar 7, Starknet fee token 18. ## Security after sign-in [#security-after-sign-in] These are chain-agnostic wrappers on `useCavos()` — they work identically on all three chains. They are the same primitives documented in [Passkeys](/docs/passkeys) and [Recovery](/docs/recovery), surfaced through the React hook. ### Passkey (2FA for new devices) [#passkey-2fa-for-new-devices] ```ts title="TypeScript" const { walletStatus, enrollPasskeyDefault, passkeySupported } = useCavos(); if (passkeySupported && !walletStatus.hasPasskey) { await enrollPasskeyDefault(); // prompts the platform authenticator once } ``` `walletStatus.hasPasskey` flips to true once enrolled. ### Recovery code [#recovery-code] ```ts title="TypeScript" const { setupRecovery } = useCavos(); const code = await setupRecovery(); // string — show this to the user ONCE ``` The recovery code is returned **once**. Display it immediately and tell the user to store it somewhere safe. Only its derived public key is registered on-chain — the code itself never leaves the device. ### Approve a new device [#approve-a-new-device] When a user signs in on a fresh device, `walletStatus.needsDeviceApproval` is true and they can't transact yet. If they have a synced passkey: ```ts title="TypeScript" const { walletStatus, approveDeviceWithPasskey } = useCavos(); if (walletStatus.needsDeviceApproval) { await approveDeviceWithPasskey(); // one passkey prompt authorizes this device } ``` See [Multi-device](/docs/multi-device) for the full device-approval flow. ## Sign messages & transactions [#sign-messages--transactions] Beyond sending transactions, a wallet needs to **sign data off-chain** (prove ownership without moving funds) and **sign transactions without submitting** (hand them to your own relayer). Both are available on every chain. ### `signMessage` — off-chain signature [#signmessage--off-chain-signature] Sign an arbitrary message with the wallet's signing key. Nothing is submitted, no gas is paid, no state changes. Returns a uniform `MessageSignature`: ```ts const { signMessage } = useCavos(); // or wallet.signMessage(...) const { signature, publicKey, curve } = await signMessage("Sign in to dapp.com"); ``` | Field | Meaning | | ----------- | ---------------------------------------------------------------------------------------------------- | | `signature` | 64-byte signature (`r‖s` for secp256r1, ed25519 raw). | | `publicKey` | The verifier input — Starknet/Solana: the device P-256 key (hex); Stellar: the `G…` control address. | | `curve` | `"secp256r1"` (Starknet, Solana) or `"ed25519"` (Stellar). Determines how to verify. | **Per-chain details:** * **Starknet** — signs `sha256("Cavos Signed Message:\n\n" + message)` with the device P-256 key. `publicKey` is the uncompressed `04‖x‖y` hex. * **Solana** — signs `sha256("\x18Solana Signed Message:\n\n" + message)`, matching the wallet-adapter `signMessage` convention. `publicKey` is the 33-byte compressed P-256 hex. * **Stellar** — the signing key is the **ed25519 control key** (not P-256), so `curve` is `"ed25519"` and `publicKey` is the `G…` control address. **Verifying off-chain** (what a third party / backend does): ```ts title="TypeScript" import { p256 } from "@noble/curves/p256"; import { sha256 } from "@noble/hashes/sha256"; import { Keypair } from "@stellar/stellar-sdk"; if (curve === "secp256r1") { // Starknet / Solana — verify the P-256 signature over sha256(prefixedMessage). const valid = p256.verify(signature, sha256(prefixedMessage), publicKeyHex); } else { // Stellar — ed25519 verify against the control address. const kp = Keypair.fromPublicKey(publicKey); // G… address const valid = kp.verify(prefixedMessage, signature); } ``` The verifier must reconstruct the same prefixed message the signer used (see the prefix per chain above). For Sign-In-with-X flows, embed a nonce + domain in the message string. ### `signTransaction` — sign without submit [#signtransaction--sign-without-submit] Sign a transaction WITHOUT broadcasting it. Each chain returns its own signed artifact (narrow on `chain`): ```ts title="TypeScript" const { wallet } = useCavos(); if (wallet?.chain === "starknet") { const signed = await wallet.signTransaction(calls); // → { calldata, signature, nonce, resourceBounds, version } } else if (wallet?.chain === "stellar") { const signed = await wallet.signTransaction(amount, destination); // → { xdr } } else if (wallet?.chain === "solana") { const signed = await wallet.signTransaction(amount, destination); // → { message, signature, publicKey } } ``` What you get back, and what to do with it: * **Starknet** — the signed invoke (`calldata`, the 5-felt device `signature`, `nonce`, `resourceBounds`, `version`). A relayer broadcasts it via the account's `invokeFunction`. * **Stellar** — the control-signed inner transaction as base64 **XDR**. Submit it directly via Horizon, or hand it to a relayer to fee-bump (the control signature stays valid through the wrap). * **Solana** — the device's P-256 signature over the transfer message: `{ message, signature, publicKey }`. **Solana's `signTransaction` is NOT a signed Solana transaction.** The device P-256 key never signs the Solana transaction itself — it signs a domain-tagged message verified on-chain by the native secp256r1 precompile. A relayer or feePayer must take this `(message, signature, publicKey)` triple, assemble the `[secp256r1 precompile ix, execute_transfer ix]` bundle, add a recent blockhash * the feePayer's signature, and submit. **All `signTransaction` outputs are single-use and bind to on-chain state.** Starknet binds to the nonce + resource bounds; Stellar to the sequence number (and a 180s timeout); Solana to the on-chain account nonce. If any other transaction from the same account is submitted first, the signature becomes invalid. Broadcast promptly. # Quickstart (/docs/quickstart) This page takes you from install to a gasless transaction. The happy path is a single `Cavos.connect` call. ## 1. Install [#1-install] ```bash title="Terminal" npm install @cavos/kit ``` Requirements: a Cavos **App ID** and a **paymaster API key** (from the Cavos dashboard at [cavos.xyz](https://cavos.xyz)). ## 2. Connect [#2-connect] `Cavos.connect` does everything: authenticate the user, derive the deterministic address, create/load the silent device key, auto-deploy the account on first use, and wire up the gas sponsor. ```ts title="TypeScript" import { Cavos } from "@cavos/kit"; const cavos = await Cavos.connect({ chain: "starknet", // "starknet" | "solana" | "stellar" network: "testnet", // "testnet" (sepolia) | "mainnet" appSalt: "my-app", // namespaces addresses to your app identity: { // from your login (see Authentication) userId: user.id, email: user.email, }, appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, }); console.log(cavos.address); // deterministic; deployed on first connect ``` `connect` returns a `Cavos` instance with: * `cavos.address` — the smart account address (stable across devices). * `cavos.status` — `"ready"` or `"needs-device-approval"` (see [Multi-device](/docs/multi-device)). * `cavos.publicKey` — this device's public key. ## 3. Execute a transaction [#3-execute-a-transaction] When `status` is `"ready"`, call `execute` with standard Starknet calls. It is signed silently by the device key and sponsored by the paymaster — the user sees nothing. ```ts title="TypeScript" if (cavos.status === "ready") { const { transactionHash } = await cavos.execute([ { contractAddress: STRK_TOKEN, entrypoint: "approve", calldata: [spender, amountLow, amountHigh], }, ]); console.log(transactionHash); } ``` If `status` is `"needs-device-approval"`, this device is new to an existing wallet. Approve it from an already-registered device — see [Multi-device](/docs/multi-device). ## 4. Set up recovery (recommended) [#4-set-up-recovery-recommended] So the user can recover if they lose every device, register a passphrase-derived backup signer once: ```ts title="TypeScript" import { generateRecoveryCode } from "@cavos/kit"; const code = generateRecoveryCode(); // show this to the user ONCE to store await cavos.setupRecovery(code); // registers the backup signer on-chain ``` The code never leaves the device — only its derived public key is registered. See [Recovery](/docs/recovery) for the full lifecycle. ## 5. Set up a passkey (recommended) [#5-set-up-a-passkey-recommended] So the user can log in on a new device without finding an already-logged-in one, prompt them for a **passkey** right after signup. Treat it as a 2FA step — it approves new devices, it never signs transactions. ```ts title="TypeScript" import { PasskeySigner } from "@cavos/kit"; if (await PasskeySigner.isSupported()) { const passkey = new PasskeySigner(); await cavos.enrollPasskey(passkey, { userId: user.id, userName: user.email ?? user.id, }); // gasless; registers the passkey's pubkey as a device approver } ``` Later, on a new device, the user confirms one prompt to authorize that device — no second device required. See [Passkeys](/docs/passkeys) for the full flow. ## What you get [#what-you-get] * A deployed, gas-sponsored smart account controlled by a silent device key. * A deterministic address you can reference before deployment. * Multi-device + passphrase recovery, all non-custodial. Next: wire up real login in [Authentication](/docs/auth), or read how signing works under the hood in [Signing & gasless](/docs/signing). # React (/docs/react) `@cavos/kit` ships React bindings under the `@cavos/kit/react` subpath (same package). Wrap your app once in `` and every descendant can call `useCavos()` to read wallet state and trigger actions — no prop drilling, no imperative `connect` calls. ```tsx title="Terminal" npm install @cavos/kit ``` ```tsx title="app/provider.tsx" import { CavosProvider } from "@cavos/kit/react"; export function Providers({ children }) { return ( {children} ); } ``` The provider manages **exactly one chain**. Behind the scenes it resolves the identity, deploys the device-signer account gaslessly on first use, and keeps the wallet handle ready for sponsored transactions. See [Chains](/docs/chains) for the chain model. ## `useCavos()` [#usecavos] The primary hook. Returns the full wallet surface: ```tsx import { useCavos } from "@cavos/kit/react"; function Wallet() { const { address, isAuthenticated, walletStatus, logout } = useCavos(); if (!isAuthenticated) return ; return
{address} · ready={walletStatus.isReady}
; } ``` ### State [#state] | Field | Description | | ----------------------------- | ---------------------------------------------------------------------------------------------- | | `isAuthenticated: boolean` | Whether a wallet is connected and ready. | | `user: UserInfo \| null` | `{ userId, email?, provider? }` from the login. | | `wallet: CavosWallet \| null` | The connected wallet, discriminated by `wallet.chain`. Narrow on it before chain-native calls. | | `address: string \| null` | The deterministic account address. | | `chain: Chain` | The active chain (`"starknet" \| "solana" \| "stellar"`). | | `walletStatus: WalletStatus` | Deployment / device-approval / passkey flags (see below). | | `isLoading: boolean` | True during an in-flight login or connect. | | `authError: string \| null` | Last unrecoverable auth/connect error. Null while healthy. | | `passkeySupported: boolean` | Whether this device/browser can use a platform passkey. | `WalletStatus`: `{ isDeploying, isReady, needsDeviceApproval, awaitingApproval, pendingRequestId, hasPasskey, isNewAccount }`. Gate any transaction on `walletStatus.isReady`. ### Login [#login] | Method | Description | | -------------------------- | -------------------------------------------------------------------- | | `login(provider)` | OAuth social login (`"google" \| "apple"`) — opens the hosted flow. | | `sendOtp(email)` | Send an email OTP code. | | `verifyOtp(email, code)` | Verify an OTP / complete a magic link and deploy the wallet. | | `sendMagicLink(email)` | Send a passwordless magic-link email. | | `handleCallback(authData)` | Resolve identity from an OAuth callback (`?auth_data=…`) and deploy. | ### Transactions [#transactions] | Method | Description | | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `execute(calls, opts?)` | **Starknet only.** Multicall signed by the device key, gasless by default. Returns `{ transactionHash }`. | | `signMessage(message)` | **All chains.** Sign an arbitrary message off-chain; returns a uniform `MessageSignature`. See [After sign-in](/docs/post-login). | On **Solana and Stellar**, call the wallet directly for chain-native actions instead of the context's `execute`: narrow on `wallet.chain`, then `wallet.execute(amount, destination)`. For `signTransaction` (sign without submit), also call `wallet.signTransaction(...)` directly after narrowing — the args differ per chain. See [After sign-in](/docs/post-login). ### Device & recovery [#device--recovery] | Method | Description | | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `addSigner(pubkey)` | Authorize another device signer (gasless `add_signer`). | | `enrollPasskeyDefault()` | Enroll a synced passkey as an approver (2FA for new devices). Requires a ready device. | | `approveDeviceWithPasskey()` | From a `needs-device-approval` browser, prompt the synced passkey to authorize this device. | | `setupRecovery()` | Register a backup signer derived from a generated recovery code. **Resolves with the code** — show it to the user once. | | `recover(code)` | Recover access after losing every device. Brings the provider to a ready state. | | `resendDeviceApproval()` | Re-request the device-approval email for the current pending request. | These wrappers are **chain-agnostic** — they work the same on Starknet, Solana, and Stellar. See [Passkeys](/docs/passkeys) and [Recovery](/docs/recovery) for the full lifecycle. ### Modal & session [#modal--session] | Method | Description | | ------------------------------ | ---------------------------------------------------------------------------------------------- | | `openModal()` / `closeModal()` | Open/close the built-in auth modal (the provider mounts it automatically when `modal` is set). | | `logout()` | Sign out and clear local wallet state. | | `clearAuthError()` | Clear `authError` (e.g. when the user starts a new login attempt). | ## `useCavosAuth()` [#usecavosauth] A deliberately thin subset for components that only need to open the modal and read basic auth state — no wallet, no transactions: ```ts { openModal, closeModal, isAuthenticated, address, user, walletStatus, logout, } ``` ## `` [#cavosauthmodal] A fully customizable, themeable login modal. The provider mounts it automatically when you pass `modal={...}`; you can also render it directly (for a live preview, or to control its lifecycle yourself). ```tsx import { CavosAuthModal } from "@cavos/kit/react"; setOpen(false)} appName="My App" appLogo="https://myapp.com/logo.png" appLogoSize={56} providers={["email", "google", "apple"]} emailMode="otp" primaryColor="#402AFF" theme="light" radius={16} secureStep="optional" /> ``` ### Props [#props] | Prop | Type | Description | | ----------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `open` | `boolean` | Whether the modal is shown (ignored when `inline`). | | `onClose` | `() => void` | Called when the user dismisses the modal. | | `appName` | `string?` | Shown in the heading (`"Sign in to {appName}"`). | | `appLogo` | `string?` | Image URL for the logo. If omitted, the Cavos star is shown. The provider also loads it from your app's dashboard config automatically; a local `appLogo` overrides that. | | `appLogoSize` | `number?` | Logo height in px. Defaults to `40` (image) / `34` (Cavos star). | | `providers` | `("google" \| "apple" \| "email")[]?` | Which login buttons to show. Defaults to all three. | | `emailMode` | `"magic-link" \| "otp"?` | How the email provider authenticates. Defaults to `magic-link`. | | `primaryColor` | `string?` | Accent color for buttons / focus rings (hex). | | `theme` | `"light" \| "dark"?` | Card theme. Defaults to `light`. | | `backgroundColor` | `string?` | Override the card background (defaults to white / `#111` per theme). | | `radius` | `number?` | Card & button corner radius in px (card defaults to `16`). | | `inline` | `boolean?` | Render the card in-flow (no overlay/backdrop) — for live previews. When `true`, `open` is ignored. | | `secureStep` | `"optional" \| "required" \| "off"?` | The one-time "secure your account" step after a new account. `optional` (default) shows Skip; `required` forces it; `off` skips it. | | `onSuccess` | `(address: string) => void?` | Fired when authentication + deployment succeed. | ## `CavosModalConfig` [#cavosmodalconfig] The object you pass as `modal={...}` to ``. Same fields as `CavosAuthModalProps` except no `open` / `onClose` / `inline` (the provider manages those), plus `onSuccess`. ## Theming [#theming] All visual props (`primaryColor`, `theme`, `backgroundColor`, `radius`, `appLogo`, `appLogoSize`) apply identically whether you set them on `` or on a direct ``. ```tsx ``` ## Mobile: automatic bottom sheet [#mobile-automatic-bottom-sheet] On viewports **`max-width: 640px`** the modal becomes a **bottom sheet** — it slides up from the bottom with rounded top corners and a grab handle, overlaying the page with a blurred backdrop. This is fully automatic; there is no prop to toggle. On desktop the modal renders centered. The `inline` mode disables the overlay/sheet entirely (the card is embedded in your layout). ## Multi-chain in React [#multi-chain-in-react] The provider is single-chain by design. To let a user switch chains, **remount the provider** with a new `key` — this resets auth state and connects a fresh wallet for the new chain: ```tsx const [chain, setChain] = useState<"solana" | "stellar" | "starknet">("solana"); ``` Changing the `key` signs the user out — each chain has a separate wallet and session. Surface this in your UI (e.g. "Switching chain signs you out"). `Chain` and `NetworkEnv` types are exported from the **core** entry (`import type { Chain } from "@cavos/kit"`), not from `@cavos/kit/react`. # Recovery (/docs/recovery) If a user loses **every** device, multi-device approval can't help — there's no existing signer to approve a new one. The fallback is a **passphrase-derived backup signer**: a second key the user controls, derived from a recovery code, registered on-chain as an ordinary signer. It is fully non-custodial — the code never leaves the device and Cavos never sees it. ## Set up recovery (once) [#set-up-recovery-once] After a successful `connect`, generate a recovery code, show it to the user to store somewhere safe, and register its derived signer: ```ts title="TypeScript" import { generateRecoveryCode } from "@cavos/kit"; const code = generateRecoveryCode(); // high-entropy; display ONCE for the user to save await cavos.setupRecovery(code); // registers the backup signer on-chain (idempotent) ``` `setupRecovery` derives a deterministic secp256r1 keypair from the code (PBKDF2 + HKDF) and registers **only its public key** via `add_signer`. The code itself stays on the device. It is idempotent — calling it again with the same code is a no-op. The recovery code is the only way to recover after total device loss. If the user loses it **and** all devices, the wallet cannot be recovered. Cavos cannot reset it — that is what non-custodial means. ## Recover on a new device [#recover-on-a-new-device] When the user has lost their devices, they enter the recovery code on a fresh device. `Cavos.recover` re-derives the backup signer, uses it to authorize the new device's key on-chain, and returns a ready wallet: ```ts title="TypeScript" import { Cavos } from "@cavos/kit"; const cavos = await Cavos.recover({ code, // the recovery code the user saved identity: { userId: user.id, email: user.email }, network: "testnet", appSalt: "my-app", appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, }); // The new device is now an authorized signer; cavos is ready to use. await cavos.execute(calls); ``` ## How it stays non-custodial [#how-it-stays-non-custodial] | | | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | **Code derivation** | The recovery code → keypair derivation runs entirely on the device. Cavos never receives the code or the private key. | | **On-chain authority** | Recovery works by the backup signer (a key the user holds) signing `add_signer` for the new device. The contract validates it like any other signer. | | **No backend key** | The Cavos backend holds no key material and has no privileged contract role. It cannot add a signer under any circumstances. | This is the best recovery model available without re-introducing custody. The two fallbacks compose: 1. **Multiple devices** ([multi-device](/docs/multi-device)) — approve a new device from an existing one. Best UX; encourage it at onboarding. 2. **Passphrase backup** (this page) — recover from a saved code when all devices are gone. See [Concepts](/docs/concepts) for the full security model. # Signing & gasless (/docs/signing) The defining feature of `@cavos/kit` is **silent signing**: the device key signs transactions with no prompt, no biometric, no popup. This page explains the mechanism and the gasless execution path. ## The silent device key [#the-silent-device-key] On the web, the device signer is a **non-extractable secp256r1 (P-256) key** held in IndexedDB via WebCrypto. "Non-extractable" means the private key is never visible to JavaScript — it can sign, but it can't be read or copied out. ```ts title="TypeScript" import { WebCryptoSigner } from "@cavos/kit"; // Created/loaded automatically by Cavos.connect, keyed by the wallet address. const signer = await WebCryptoSigner.loadOrCreate({ keyId: address }); const pubkey = await signer.getPublicKey(); ``` `Cavos.connect` handles this for you — you rarely touch the signer directly. ## How a signature is built [#how-a-signature-is-built] When you call `cavos.execute(calls)`: 1. starknet.js computes the v3 transaction hash. 2. The device key signs `sha256(tx_hash)` — WebCrypto's ECDSA hashes the message internally, so there is no user interaction. 3. The signature is serialized as **5 felts**: `[r_low, r_high, s_low, s_high, y_parity]`. 4. On-chain, `DeviceAccount.__validate__` recomputes `sha256(tx_hash)`, normalizes high-`s`, recovers the secp256r1 signer, and checks it is authorized. This 5-felt encoding is byte-compatible with the contract and covered by a cross-checked test in the account-contracts repo. ## Gasless execution [#gasless-execution] `cavos.execute` routes through the **Cavos paymaster** (SNIP-9 `execute_from_outside`), so the user pays no gas. You supply the paymaster API key at connect time: ```ts title="TypeScript" const cavos = await Cavos.connect({ chain: "starknet", network: "testnet", appSalt: "my-app", identity, appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, // sponsors deploy + execute }); const { transactionHash } = await cavos.execute([ { contractAddress, entrypoint, calldata }, ]); ``` Both **account deployment** (on first connect) and **every execution** are sponsored — the user never needs ETH or STRK to start. ## Using a plain starknet.js Account (advanced) [#using-a-plain-starknetjs-account-advanced] `StarknetDeviceSigner` is a drop-in starknet.js `SignerInterface`, so you can plug the device key into a standard `Account` and your own paymaster flow: ```ts title="TypeScript" import { Account, RpcProvider } from "starknet"; import { StarknetDeviceSigner, WebCryptoSigner } from "@cavos/kit"; const provider = new RpcProvider({ nodeUrl: "https://api.cartridge.gg/x/starknet/sepolia" }); const signer = await WebCryptoSigner.loadOrCreate({ keyId: address }); const account = new Account({ provider, address, signer: new StarknetDeviceSigner(signer), cairoVersion: "1", }); ``` **Gas estimation gotcha (self-funded path).** secp256r1 validation is heavy (\~27M L2 gas). Default fee estimation uses `SKIP_VALIDATE`, which under-bounds and causes "Out of gas". Estimate with validation on and pass those bounds: ```ts title="TypeScript" const fee = await account.estimateInvokeFee(calls, { skipValidate: false }); await account.execute(calls, { resourceBounds: fee.resourceBounds }); ``` The Cavos paymaster path handles this for you — this only matters when you submit transactions yourself. ## Security trade-off [#security-trade-off] Because signing is silent, there is **no per-signature user-verification gate** — every approved call goes through with no prompt. This is the standard embedded-wallet trade-off (Privy / Turnkey model): maximum UX, no friction per transaction. The key is still non-custodial, device-bound, and verified on-chain. That gate is intentionally *not* on signing. Instead, Cavos puts the user-verification step on **device management**: an optional [passkey](/docs/passkeys) acts as a second factor for approving *new devices*, not for approving each transaction. Combined with [multi-device](/docs/multi-device) and [recovery](/docs/recovery), that covers device loss without breaking the silent-signing UX. # Solana (/docs/solana) Solana is the second implemented chain in `@cavos/kit`. As on Starknet, a deterministic address derived from the user's identity is controlled by a silent secp256r1 (P-256) device key. The difference is how that key is verified on-chain: instead of an account contract recovering the secp256r1 signer, every guarded action is a **two-instruction bundle** that pairs Solana's **native secp256r1 signature-verify precompile** with the Cavos device-account program instruction. Gas is sponsored by the Cavos relayer (co-signs as fee payer), so the integrator needs no fee-payer keypair and the user holds no SOL. ## Connect [#connect] Use the same unified `Cavos.connect`, passing `chain: "solana"`. An `appId` is what activates gasless sponsorship — no `paymasterApiKey` is used on Solana. ```ts title="TypeScript" import { Cavos } from "@cavos/kit"; const wallet = await Cavos.connect({ chain: "solana", // selects the Solana path network: "testnet", // "testnet" (devnet) | "mainnet" appSalt: "my-app", // namespaces addresses to your app identity: { // from your login (see Authentication) userId: user.id, email: user.email, }, appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, // activates the gasless relayer }); console.log(wallet.address); // deterministic device-account PDA ``` `network: "testnet"` resolves to `solana-devnet`; `"mainnet"` resolves to `solana-mainnet`. The returned `wallet` is a discriminated union, so narrow on `wallet.chain` before calling chain-native methods. **Pass your own RPC on mainnet.** Without `rpcUrl`, the kit falls back to the public `api.mainnet-beta.solana.com` endpoint, which is rate-limited and unfit for production. Provide a real provider (Helius / Triton / QuickNode) via `rpcUrl` in production. ## Execute a transfer [#execute-a-transfer] On Solana, `execute(amount, destination)` moves `amount` **lamports** out of the device account to `destination`. It is signed silently by the device key and sponsored by the relayer. ```ts title="TypeScript" if (wallet.chain === "solana" && wallet.status === "ready") { const signature = await wallet.execute( 1_000_000n, // 0.001 SOL, in lamports recipientPublicKey, // base58 address ); console.log(signature); // confirmed transaction signature } ``` The `amount` is a `bigint` of lamports (`1 SOL = 1_000_000_000 lamports`). The returned value is the Solana **transaction signature**, not a Starknet felt. ## Execute arbitrary program calls [#execute-arbitrary-program-calls] For anything beyond a SOL transfer (SPL token transfers, swaps, staking, …), `executeInstructions(instructions)` runs arbitrary CPIs with the device-account PDA as a signer: ```ts title="TypeScript" import { createTransferInstruction, getAssociatedTokenAddress } from "@solana/spl-token"; import type { InstructionData } from "@cavos/kit"; // Build an SPL Token transfer instruction (the device PDA owns the source ATA). const source = await getAssociatedTokenAddress(usdcMint, new PublicKey(wallet.address)); const dest = await getAssociatedTokenAddress(usdcMint, recipientPublicKey); const ix = createTransferInstruction(source, dest, new PublicKey(wallet.address), amount, [], undefined, TOKEN_PROGRAM_ID); // The kit needs the instruction in its serializable shape — copy it over. const instructions: InstructionData[] = [{ programId: ix.programId.toBase58(), accounts: ix.keys.map((k) => ({ pubkey: k.pubkey.toBase58(), isSigner: k.isSigner, isWritable: k.isWritable })), data: ix.data, }]; if (wallet.chain === "solana" && wallet.status === "ready") { const signature = await wallet.executeInstructions(instructions); } ``` The device key signs over `sha256` of the canonical Borsh serialization of the instruction set, so the signature commits to **exactly** the CPIs the program will invoke — no account/data substitution after signing. **Sponsorship is allowlisted.** The relayer only co-signs an `execute` whose CPI targets are in the app's **Solana program allowlist** (dashboard → Solana Programs) **plus** an always-safe set (System, SPL Token, Token-2022, Associated Token). Programs outside both are rejected before co-signing. A per-transaction compute-unit cap (1,000,000 CU) also bounds the compute Cavos will sponsor. If `status` is `"needs-device-approval"`, this device is new to an existing wallet. Approve it from an already-registered device with `wallet.addSigner` — the same model as [Multi-device](/docs/multi-device) on Starknet. ## How signing works [#how-signing-works] Each guarded action (initialize, add/remove signer, execute) is a **two-instruction bundle**: 1. **The secp256r1 precompile instruction** (`Secp256r1SigVerify`) records the device's P-256 signature of the action's domain-separated message (`cavos:transfer:v1`, `cavos:add_signer:v1`, …), making the signed message and signer observable to the next instruction. 2. **The program instruction** (e.g. `execute_transfer`) reads the verified signer off the precompile's account and checks it is an authorized signer of the device account before acting. The fee payer is not bound by the device signature, so the relayer can co-sign as fee payer without re-authorizing the action. This is the standard Solana pattern for non-Ed25519 signers. ## Gasless vs self-funded [#gasless-vs-self-funded] | Path | When | Who pays | | ------------------------------ | ------------------------------------------ | --------------------------------------- | | **Relayer (gasless, default)** | `appId` is set | The Cavos relayer co-signs as fee payer | | **Self-funded fallback** | no `appId`, a `feePayer` Keypair is passed | Your integrator-funded keypair | With the relayer, the integrator holds no fee-payer keypair and the user holds no SOL — a seedless, gasless experience. The self-funded path is for tests or advanced flows where you bring your own payer. ## Address derivation [#address-derivation] On Solana the identity seed is a **SHA-256** 32-byte `address_seed` (`deriveAddressSeedSolana`), and the account is a deterministic **PDA**: ``` [cavos-account, addressSeed, deviceKeyX] ``` Like Starknet, the address depends only on identity + salt, never on a private key — so the same user always lands on the same account, computable before deployment. ## Networks & constants [#networks--constants] ```ts title="TypeScript" import { SOLANA_NETWORKS, DEVICE_ACCOUNT_PROGRAM_ID } from "@cavos/kit"; SOLANA_NETWORKS["solana-devnet"]; // https://api.devnet.solana.com DEVICE_ACCOUNT_PROGRAM_ID; // deployed cavos-device-account program id ``` Networks: `"solana-devnet"`, `"solana-mainnet"` (and `"solana-localnet"` for local-validator testing). The program id ships with the kit; override it with `programId` in `connect` if you deploy your own. ## Recovery [#recovery] Recovery works the same as on Starknet — self-custodial, code-based. Generate a recovery code, register its derived backup signer, and store the code. If every device is lost, the code re-derives the backup key that authorizes a new device. ```ts title="TypeScript" import { generateRecoveryCode } from "@cavos/kit"; // On a registered device — run once, have the user store the code: const code = generateRecoveryCode(); await wallet.setupRecovery(code); // registers the backup signer (idempotent) // → show `code` to the user once; the kit never persists it. ``` ```ts title="TypeScript" // After losing every device — enter the stored code: import { CavosSolana } from "@cavos/kit"; const wallet = await CavosSolana.recover({ code, identity: { userId, email }, network: "testnet", // -> solana-devnet appSalt: "my-app", appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, }); await wallet.execute(...); // ready, bound to the new device ``` The backup key is just another authorized signer, so recovery needs no special on-chain entrypoint — it's an `add_signer(newDevice)` bundle signed by the backup key. See [Recovery](/docs/recovery) for the model. ## What's available [#whats-available] * ✅ Silent secp256r1 device signer, auto-initialized device-account PDA. * ✅ Gasless execute + initialize via the Cavos relayer. * ✅ **Arbitrary program calls** via `executeInstructions` (SPL transfers, swaps, staking) — gated by the app's program allowlist. * ✅ Multi-device add-signer (device-approved). * ✅ **Recovery** (self-custodial, code-based) — `setupRecovery` / `recover`. For the cross-chain model and how this compares to Starknet, see [Chains](/docs/chains) and [Starknet](/docs/starknet). # Starknet (/docs/starknet) Starknet is the first implemented chain in `@cavos/kit`. A deterministic address derived from the user's identity is controlled by a silent secp256r1 (P-256) device key, validated on-chain by the Cairo `DeviceAccount` account contract. Deploy and execution are sponsored by the Cavos paymaster, so the user pays no gas. ## Connect [#connect] `Cavos.connect` with `chain: "starknet"` (the default) does everything: authenticate, derive the deterministic address, create/load the silent device key, auto-deploy the account on first use, and wire up the gas sponsor. ```ts title="TypeScript" import { Cavos } from "@cavos/kit"; const wallet = await Cavos.connect({ chain: "starknet", // default; shown for clarity network: "testnet", // "testnet" (sepolia) | "mainnet" appSalt: "my-app", // namespaces addresses to your app identity: { // from your login (see Authentication) userId: user.id, email: user.email, }, appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, paymasterApiKey: process.env.CAVOS_PAYMASTER_API_KEY!, // sponsors deploy + execute }); console.log(wallet.address); // deterministic; deployed on first connect ``` `wallet.chain` narrows to `"starknet"`. The instance members (`address`, `status`, `execute`, `addSigner`, `setupRecovery`) are the ones documented in the [API reference](/docs/api-reference). ## Execute a transaction [#execute-a-transaction] When `status === "ready"`, call `execute` with standard Starknet calls. It is signed silently by the device key and sponsored by the paymaster — the user sees nothing. ```ts title="TypeScript" if (wallet.chain === "starknet" && wallet.status === "ready") { const { transactionHash } = await wallet.execute([ { contractAddress: STRK_TOKEN, entrypoint: "approve", calldata: [spender, amountLow, amountHigh], }, ]); console.log(transactionHash); } ``` If `status` is `"needs-device-approval"`, this device is new to an existing wallet. Approve it from an already-registered device — see [Multi-device](/docs/multi-device). ## How signing works [#how-signing-works] The device key is a **non-extractable secp256r1 key** held in IndexedDB via WebCrypto. When you call `execute`: 1. starknet.js computes the v3 transaction hash. 2. The device key signs `sha256(tx_hash)` — WebCrypto's ECDSA hashes the message internally, so there is no user interaction. 3. The signature is serialized as **5 felts**: `[r_low, r_high, s_low, s_high, y_parity]`. 4. On-chain, `DeviceAccount.__validate__` recomputes `sha256(tx_hash)`, normalizes high-`s`, recovers the secp256r1 signer, and checks it is authorized. This 5-felt encoding is byte-compatible with the contract and covered by a cross-checked test in the `account-contracts` repo. ## Gasless execution [#gasless-execution] `execute` routes through the **Cavos paymaster** (SNIP-9 `execute_from_outside`), so the user pays no gas. Both **account deployment** (on first connect) and **every execution** are sponsored — the user never needs ETH or STRK to start. ## Advanced: a plain starknet.js Account [#advanced-a-plain-starknetjs-account] `StarknetDeviceSigner` is a drop-in starknet.js `SignerInterface`, so you can plug the device key into a standard `Account` and your own paymaster flow: ```ts title="TypeScript" import { Account, RpcProvider } from "starknet"; import { StarknetDeviceSigner, WebCryptoSigner } from "@cavos/kit"; const provider = new RpcProvider({ nodeUrl: "https://api.cartridge.gg/x/starknet/sepolia" }); const signer = await WebCryptoSigner.loadOrCreate({ keyId: address }); const account = new Account({ provider, address, signer: new StarknetDeviceSigner(signer), cairoVersion: "1", }); ``` **Gas estimation gotcha (self-funded path).** secp256r1 validation is heavy (\~27M L2 gas). Default fee estimation uses `SKIP_VALIDATE`, which under-bounds and causes "Out of gas". Estimate with validation on and pass those bounds: ```ts title="TypeScript" const fee = await account.estimateInvokeFee(calls, { skipValidate: false }); await account.execute(calls, { resourceBounds: fee.resourceBounds }); ``` The Cavos paymaster path handles this for you — this only matters when you submit transactions yourself. ## Networks & constants [#networks--constants] ```ts title="TypeScript" import { STARKNET_NETWORKS, DEVICE_ACCOUNT_CLASS_HASH } from "@cavos/kit"; STARKNET_NETWORKS.sepolia; // RPC + chain config DEVICE_ACCOUNT_CLASS_HASH.sepolia; // deployed DeviceAccount class hash ``` `network` accepts `"testnet"` (resolves to `sepolia`) and `"mainnet"`. The class hash is the declared `DeviceAccount` contract; the kit ships the current values. ## What's available [#whats-available] * ✅ Silent secp256r1 device signer, auto-deployed deterministic account. * ✅ Gasless execute + deployment via the Cavos paymaster. * ✅ [Multi-device](/docs/multi-device) approval + [recovery](/docs/recovery) (passphrase backup) — non-custodial. For the cross-chain model and how Solana compares, see [Chains](/docs/chains) and [Solana](/docs/solana). # Stellar (/docs/stellar) On Stellar, `@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 deterministic `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. If you've used the [Starknet](/docs/starknet) or [Solana](/docs/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 deterministic master key (weight 0) fixes the address, and a random **control key** (weight 1) does the signing. The control key is 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 [#connect] Use the same unified `Cavos.connect`, passing `chain: "stellar"`. An `appId` activates the gasless relayer (it sponsors the account's XLM reserves and pays fees). No `paymasterApiKey` is used on Stellar. ```ts title="TypeScript" import { Cavos } from "@cavos/kit"; const wallet = await Cavos.connect({ chain: "stellar", // selects the classic-Stellar path network: "testnet", // "testnet" | "mainnet" appSalt: "my-app", // namespaces addresses to your app identity: { // from your login (see Authentication) userId: user.id, email: user.email, }, appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, // activates the gasless relayer }); console.log(wallet.address); // deterministic G… master address ``` `network: "testnet"` resolves to `stellar-testnet`; `"mainnet"` resolves to `stellar-mainnet`. The returned `wallet` is a discriminated union, so narrow on `wallet.chain === "stellar"` before calling chain-native methods. `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`. ## Execute a transfer [#execute-a-transfer] On Stellar, `execute(amount, destination)` moves `amount` **stroops** of native XLM out of the account to `destination`. It is signed by the control key (unlocked silently from the on-chain envelope) and, by default, sponsored by the relayer, which fee-bumps and pays the fee. ```ts title="TypeScript" if (wallet.chain === "stellar" && wallet.status === "ready") { const hash = await wallet.execute( 10_000_000n, // 1 XLM, in stroops "GDESTINATION...ADDRESS", // recipient G… address ); console.log(hash); // Horizon transaction hash } ``` `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): ```ts title="TypeScript" const stroops = await wallet.balance(); // bigint, native XLM ``` ## Unlock factors & silent 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) [#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. ```ts title="TypeScript" import { PasskeyPrf } from "@cavos/kit"; // On a ready device, right after signup: 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 ``` The React `CavosProvider` wraps this as `enrollPasskeyDefault()` / `approveDeviceWithPasskey()`, so app code never touches `PasskeyPrf` directly. ## Approve a new device [#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: ```ts title="TypeScript" const wallet = await Cavos.connect({ chain: "stellar", /* ...same identity + appSalt... */ }); 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](/docs/multi-device) for the cross-chain model. ## Recovery [#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: ```ts title="TypeScript" import { generateRecoveryCode } from "@cavos/kit"; // On a ready device — run once, have the user store the code: const code = generateRecoveryCode(); await wallet.setupRecovery(code); // writes the cv:wr factor (idempotent) ``` ```ts title="TypeScript" // After losing every device — reconnect on the new device, then approve it: const wallet = await Cavos.connect({ chain: "stellar", /* ...same identity + appSalt... */ }); 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](/docs/recovery) for the model. ## Gasless vs self-funded [#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 derivation [#address-derivation] The `G…` address is the deterministic master key derived from identity + salt (`deriveStellarAddress` / `deriveStellarMasterKeypair`), so the same user always lands on the same account, computable before creation. The master is set to weight 0 at creation; the control key (weight 1) does the signing. ```ts title="TypeScript" import { deriveStellarAddress } from "@cavos/kit"; const address = deriveStellarAddress({ userId: user.id, appSalt: "my-app" }); // G… ``` ## Networks & constants [#networks--constants] ```ts title="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 [#whats-available] * ✅ Deterministic classic `G…` multisig account (master weight 0 + on-chain sealed control key), self-custodial with no backend/registry. * ✅ Silent signing via the control key unwrapped by this device's ECDH key. * ✅ Gasless create + execute via the Cavos relayer (reserves sponsored). * ✅ Native XLM transfer (`execute(amount, destination)`) + `balance()`. * ✅ **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](/docs/chains) and [Concepts](/docs/concepts).