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.
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
| Status | Meaning | Can execute? |
|---|---|---|
undeployed | Address derived, no on-chain PDA yet | Yes — first execute initializes + runs atomically |
ready | Account deployed and this device authorized | Yes |
needs-device-approval | Account deployed but this device not authorized | No — 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.
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:
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:
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:
- 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. - 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
| 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
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
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 idNetworks: "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.
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.
// 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 deviceThe 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.
Starknet
Use @cavos/kit on Starknet — silent device signing, gasless execution via paymaster, and the Cairo DeviceAccount.
Stellar
Use @cavos/kit on Stellar — a classic G… multisig account whose control key is sealed on-chain and unlocked by a silent device key, gasless via the Cavos relayer, and able to sign Soroban contract calls and Trustless Work escrows.