Skip to main content
Version: 0.5.0

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:

FieldValue
Domain namethe 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 / primaryTypeLink { disclaimer: string, wallet: address }
message.disclaimerfixed warning text, shown in the wallet and committed in the signature (its exact text is therefore permanent)
message.walletthe 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:

  1. Mnemonic → seed via PBKDF2-SHA512 (standard BIP-39).

  2. 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_ORDER
    • signing_key = SHA-256("zkstables-signing-key-v1" ∥ seed) mod GRUMPKIN_CURVE_ORDER
    • viewing_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 with 1.

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