Cavos

Solana

Use @cavos/kit on Solana — device-bound accounts authorized by the native secp256r1 precompile, gasless via the Cavos relayer.

Solana is one of the current adapters in Cavos' every-chain architecture. 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

Use the unified Cavos.connect, passing chains: ["solana"]. An appId activates gasless sponsorship — no paymasterApiKey is used on Solana. Connect never deploys. The account is created lazily on the first execute.

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

const session = await Cavos.connect({
  chains: ["solana"],
  defaultChain: "solana",
  network: "testnet",                   // "testnet" (devnet) | "mainnet"
  appSalt: "my-app",                    // device-key namespace
  identity: {                           // from your login (see Authentication)
    userId: user.id,
    email: user.email,
  },
  appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID, // activates the gasless relayer
});

const wallet = session.wallet("solana");
console.log(wallet.address);            // deterministic device-account PDA
console.log(wallet.status);             // "undeployed" | "ready" | "needs-device-approval"

network: "testnet" resolves to solana-devnet; "mainnet" resolves to solana-mainnet. The returned wallet is discriminated by wallet.chain.

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 or rpcUrls.solana in production.

Status values

StatusMeaningCan execute?
undeployedAddress derived, no on-chain PDA yetYes — first execute initializes + runs atomically
readyAccount deployed and this device authorizedYes
needs-device-approvalAccount deployed but this device not authorizedNo — route to device approval

Lazy deploy: An undeployed wallet can sign messages and execute. The first execute or executeInstructions initializes the device-account PDA and runs the user's operation. After that, status becomes ready.

Execute a transfer

On Solana, execute(amount, destination) moves amount lamports out of the device account to destination. Both undeployed and ready wallets can execute.

TypeScript
if (wallet.chain === "solana" && wallet.status !== "needs-device-approval") {
  const signature = await wallet.execute(
    1_000_000n,                          // 0.001 SOL, in lamports
    recipientPublicKey,                  // base58 address
  );
  console.log(signature);                // confirmed transaction signature
}

When the account is undeployed, this call initializes the account first, then executes the transfer.

The amount is a bigint of lamports (1 SOL = 1_000_000_000 lamports). The returned value is the Solana transaction signature.

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:

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 !== "needs-device-approval") {
  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 on Starknet.

Sign messages

Sign arbitrary messages off-chain with the device key. Works on both undeployed and ready wallets:

TypeScript
const sig = await wallet.signMessage("Hello, Cavos!");
// sig.signature: Uint8Array
// sig.publicKey: "..." (33-byte compressed P-256 as hex)
// sig.curve: "secp256r1"

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

PathWhenWho pays
Relayer (gasless, default)appId is setThe Cavos relayer co-signs as fee payer
Self-funded fallbackno appId, a feePayer Keypair is passedYour 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

On Solana the identity seed is a SHA-256 32-byte address_seed, 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

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 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.

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 (pending if undeployed)
// → show `code` to the user once; the kit never persists it.

For undeployed wallets, setupRecovery stores the backup signer pending. It gets included when the account is initialized.

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 codes for the model.

What's available

  • ✅ Silent secp256r1 device signer, deterministic device-account PDA.
  • ✅ Lazy deploy — first execute initializes the account atomically.
  • ✅ Gasless execute + initialize via the Cavos relayer.
  • ✅ Sign messages off-chain while undeployed.
  • 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.
  • Passkeys as approvers for new-device authorization.

For the cross-chain model and how this compares to Starknet, see Chains and Starknet.

On this page