Key Management
Users derive all keys from a single BIP-39 mnemonic. The mnemonic produces a seed, from which several independent keys are derived as siblings via domain-separated SHA-256 — not in a parent-child hierarchy. This way knowing one key (e.g. an ESK shared with a relayer for proof generation) does not let an attacker derive any of the others.
The keys derived from one seed:
- Encryption Secret Key (ESK) — Grumpkin scalar. Decrypts incoming balances and serves as the witness in ZK proofs. The Encryption Public Key (EPK) is
ESK · G. The EPK is the user's on-chain identity — balances are stored under the hash of the EPK, not under an EVM address. To send someone funds you need their EPK. - Controller Spending Key (CSK) — secp256k1 scalar. Authorizes balance-decreasing operations on-chain via EIP-712 signatures. The Controller Public Key (CPK) is the corresponding Ethereum address.
- signing key — Grumpkin scalar. Reserved (Schnorr attestations, future use).
- viewing key — Grumpkin scalar. Reserved (compliance-scoped balance viewing, future use).
Spending from your own balance requires both ESK (proof witness) and CSK (controller signature) — neither alone is sufficient. This separation enables custody patterns where a relayer holds the ESK for proof generation while a custody provider (Fireblocks, Safe multisig, etc.) holds the CSK for spend authorization.
An alternative linked mode sources the seed from an EIP-712 signature produced by an existing EVM wallet (e.g. MetaMask). The signature bytes are hashed (SHA-256) into seed bytes from which only the ESK is derived; the CSK is not derived from the signature. Instead the linking EOA itself is the controller — it signs spend authorizations live. This lets users tie their privacy identity to an Ethereum account without a separate mnemonic, and means a leaked linking signature exposes only the (read-only) ESK, never spending rights. The signed message is canonical — an implementation that signs different typed data derives a different account for the same wallet:
| Field | Value |
|---|---|
Domain name | the integrator's app value (default ZkStables) — wallets show it as the requester; part of the seed, so the same wallet with a different app derives a separate account (recoverable only by re-signing with that same app), and it is fixed once per application |
Domain version | "1" — no chainId, so the derived account is chain-independent |
Types / primaryType | Link { disclaimer: string, wallet: address } |
message.disclaimer | fixed warning text, shown in the wallet and committed in the signature (its exact text is therefore permanent) |
message.wallet | the linking EOA address |
The full EIP-712 payload an integrator with app = "ZkStables Demo" asks the wallet to sign:
{
"domain": { "name": "ZkStables Demo", "version": "1" },
"primaryType": "Link",
"types": {
"EIP712Domain": [
{ "name": "name", "type": "string" },
{ "name": "version", "type": "string" }
],
"Link": [
{ "name": "disclaimer", "type": "string" },
{ "name": "wallet", "type": "address" }
]
},
"message": {
"disclaimer": "Signing this message derives the keys to your ZkStables encrypted account. Only sign it in an app you trust — a malicious app can derive your account from it. This is not a transaction.",
"wallet": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
}
}
Only standard 65-byte ECDSA signatures are accepted — smart-contract wallets (ERC-1271) are out of scope.
In institutional mode the CSK is generated and held externally instead of derived from the seed; only the ESK comes from the mnemonic.
Derivation
Starting from a BIP-39 mnemonic:
-
Mnemonic → seed via PBKDF2-SHA512 (standard BIP-39).
-
Each Grumpkin-curve key is
SHA-256(domain ∥ seed) mod GRUMPKIN_CURVE_ORDER, with the domain identifying the role:ESK = SHA-256("zkstables-encryption-key-v1" ∥ seed) mod GRUMPKIN_CURVE_ORDERsigning_key = SHA-256("zkstables-signing-key-v1" ∥ seed) mod GRUMPKIN_CURVE_ORDERviewing_key = SHA-256("zkstables-viewing-key-v1" ∥ seed) mod GRUMPKIN_CURVE_ORDER
If a derived Grumpkin scalar is zero (probability
≈ 2^-256), it is replaced with1. -
The CSK uses the same KDF construction reduced modulo the secp256k1 group order:
CSK = SHA-256("zkstables-controller-key-v1" ∥ seed) mod SECP256K1_CURVE_ORDER
Multiple accounts are supported via domain separation: account index N > 0 uses "zkstables-{role}-v1/{N}" (the unindexed string is shorthand for index 0).
Implementation: deriveSibling in packages/core/src/keys/derivation.ts (Grumpkin keys), deriveCSK in packages/core/src/keys/controller.ts (CSK).