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.
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
| Status | Meaning | Can execute? |
|---|---|---|
undeployed | Address derived, no on-chain account yet | Yes — first execute deploys + runs atomically |
ready | Account deployed and this device holds the stark key | Yes |
needs-device-approval | The registry has an address, but this device holds no key | No — 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.
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:
- starknet.js computes the v3 transaction hash.
- The stark key signs it, producing a standard 2-felt
[r, s]signature. - 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:
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 signedThe 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:
| Path | What happens |
|---|---|
| Enclave | Sign in again. The enclave unwraps the MasterDEK onto this device. |
| Passkey | Sign 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.
await wallet.upgrade(newClassHash);Key helpers
When you hold the seed yourself (your own relayer, tests), the same derivation is exported:
import {
starkKeyFromSeed,
starkPublicKey,
starknetAddressFromPublicKey,
starknetAddressFromSeed,
} from "@cavos/kit";starknetAddressFromPublicKey(publicKey) is
calculateContractAddressFromHash(publicKey, OZ_ACCOUNT_CLASS_HASH, [publicKey], 0).
Networks & constants
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.0network 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.