Cavos

Starknet

Use @cavos/kit on Starknet — a native OpenZeppelin account whose stark key is HKDF of a MasterDEK, with gasless execution via the Cavos paymaster.

Starknet in @cavos/kit is a native account, the same model as Solana and Stellar. The account is the standard, upgradeable OpenZeppelin AccountUpgradeable (v3.0.0) controlled by a stark key — the same shape as Argent or Braavos, not a custom contract.

The stark key is grindKey(HKDF(MasterDEK, appSalt, "cavos-stark-starknet-v1")). It is derived from the same MasterDEK that Solana and Stellar use, sealed in the enclave or restored from a passkey the user added. A new device restores that DEK off-chain and derives the same key. There is no on-chain device list.

Deploy and execution are sponsored by the Cavos paymaster, so the user pays no gas.

Connect

Cavos.connect with chain: "starknet" (or chains: ["starknet"]) does everything: authenticate, derive the deterministic address, unwrap the stark key on this device, and wire up the gas sponsor. Connect never deploys. The account is created lazily on the first execute.

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

const session = await Cavos.connect({
  chains: ["starknet"],
  defaultChain: "starknet",
  network: "testnet",                   // "testnet" (sepolia) | "mainnet"
  appSalt: "my-app",                    // HKDF salt — never change it
  identity: {                           // from your login (see Authentication)
    userId: user.id,
    email: user.email,
  },
  appId: process.env.NEXT_PUBLIC_CAVOS_APP_ID,
  paymasterApiKey: process.env.NEXT_PUBLIC_CAVOS_PAYMASTER_API_KEY!, // app-scoped, client-visible
});

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

wallet.chain narrows to "starknet". The instance members (address, status, execute, signMessage) are documented in the API reference.

appSalt is part of the HKDF. Changing it derives a different key and address that will not match the registry row.

Status values

StatusMeaningCan execute?
undeployedAddress derived, no on-chain account yetYes — first execute deploys + runs atomically
readyAccount deployed and this device holds the stark keyYes
needs-device-approvalThe registry has an address, but this device holds no keyNo — restore with enclave login or approveDeviceWithPasskey()

Lazy deploy: An undeployed wallet can sign messages and execute. The first execute deploys the account and runs the user's calls in a single sponsored transaction. After that, status becomes ready.

Execute a transaction

Both undeployed and ready wallets can execute. Only needs-device-approval must be handled differently.

TypeScript
if (wallet.chain === "starknet" && wallet.status !== "needs-device-approval") {
  const { transactionHash } = await wallet.execute([
    {
      contractAddress: STRK_TOKEN,
      entrypoint: "approve",
      calldata: [spender, amountLow, amountHigh],
    },
  ]);
  console.log(transactionHash);
}

When the account is undeployed, this call deploys the account first, then executes the calls — atomically in a single paymaster-sponsored transaction.

If status is "needs-device-approval", this device is new to an existing wallet and holds no key yet. Restore it by signing in again with the enclave or with a passkey the user added. See Multi-device.

How signing works

The stark key is derived from the MasterDEK and signs silently — there is no user interaction. When you call execute:

  1. starknet.js computes the v3 transaction hash.
  2. The stark key signs it, producing a standard 2-felt [r, s] signature.
  3. On-chain, the OpenZeppelin account's __validate__ checks the signature against the account's public key.

On the web the key lives inside the Cavos vault, which reads the calls before signing: transfer on ETH, STRK and USDC counts against your app's limits, and calls into the account itself always go to your over-limit rule.

One trade-off. WebCrypto cannot hold a stark key, so outside the Cavos vault the Starknet key is re-derived into memory for the session; the DEK at rest stays protected by the device's non-extractable key. Use the vault.

The kit refuses set_public_key calls on the account itself: a rotated key would no longer be controlled by the DEK.

Gasless execution

execute routes through the Cavos paymaster (SNIP-29, with SRC-9 outside execution), so the user pays no gas. Both account deployment (first execute) and every subsequent execution are sponsored — the user never needs ETH or STRK.

After the account is deployed, fee: 'self' pays from the account's own balance instead. A self-funded first execute is refused, because an undeployed account has no balance to deploy itself with.

Sign messages

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

TypeScript
const sig = await wallet.signMessage("Hello, Cavos!");
// sig.signature: Uint8Array (64 bytes, r||s)
// sig.publicKey: the stark public key
// sig.curve: "stark"
// sig.typedData: the SNIP-12 typed data that was signed

The message is signed as SNIP-12 revision 1 typed data: domain { name: "Cavos", version: "1", chainId, revision: "1" } and a Message { content: string } type. Verify it by calling the deployed account's is_valid_signature, or off-chain by recomputing the typed-data hash from typedData and the account address and checking the stark signature.

wallet.signTransaction(calls) builds and signs a multicall without submitting it and returns a 2-felt stark signature. The signature binds to the nonce and resource bounds at sign time, so broadcast it promptly.

Recovery and new devices

Recovery on Starknet works exactly like Solana and Stellar. The new device restores the MasterDEK off-chain and derives the same stark key:

PathWhat happens
EnclaveSign in again. The enclave unwraps the MasterDEK onto this device.
PasskeySign in. If a passkey was added, the vault asks for it and opens its encrypted copy of the MasterDEK.

See Recovery, Hardware-isolated recovery and Passkeys. Signing up never prompts for a passkey.

Upgrades

The account class hash is pinned: the address derives from it. Later implementation changes go through the account's upgrade entrypoint, which keeps the address.

TypeScript
await wallet.upgrade(newClassHash);

Key helpers

When you hold the seed yourself (your own relayer, tests), the same derivation is exported:

TypeScript
import {
  starkKeyFromSeed,
  starkPublicKey,
  starknetAddressFromPublicKey,
  starknetAddressFromSeed,
} from "@cavos/kit";

starknetAddressFromPublicKey(publicKey) is calculateContractAddressFromHash(publicKey, OZ_ACCOUNT_CLASS_HASH, [publicKey], 0).

Networks & constants

TypeScript
import {
  STARKNET_NETWORKS,
  CAVOS_PAYMASTER_URL,
  OZ_ACCOUNT_CLASS_HASH,
} from "@cavos/kit";

STARKNET_NETWORKS.sepolia;            // RPC + chain config
CAVOS_PAYMASTER_URL.sepolia;          // Cavos-hosted SNIP-29 paymaster
OZ_ACCOUNT_CLASS_HASH;                // OpenZeppelin AccountUpgradeable v3.0.0

network accepts "testnet" (resolves to sepolia) and "mainnet". The class hash is 0x01d1777db36cdd06dd62cfde77b1b6ae06412af95d57a13dc40ac77b8a702381, declared on mainnet and Sepolia.

What's available

  • ✅ Native stark key from the MasterDEK, deterministic address.
  • ✅ Standard, upgradeable OpenZeppelin account.
  • ✅ Lazy deploy — first execute creates the account atomically.
  • ✅ Gasless execute + deployment via the Cavos paymaster; fee: 'self' after deploy.
  • ✅ Sign messages off-chain (SNIP-12) while undeployed.
  • ✅ Enclave DEK wrap and passkey restore on a new device.
  • ❌ On-chain device list, add_signer, P-256 device keys, on-chain approvers.

For the cross-chain model and how Solana and Stellar compare, see Chains, Solana, and Stellar.

On this page