Skip to main content

Derive your protocol keys

What this lets you do

Produce the four protocol keys from one wallet signature. There is no server, no storage, and no enrollment ceremony, because the keys are a deterministic function of your wallet signature over a fixed message. If you lose the cache, you simply re-derive identical keys.

The four keys

privateNullifyingKey Used to derive nullifiers when spending. Highly sensitive: an attacker who obtains it can link your spends to known commitments, but cannot spend a note without also holding that note's secret and your revocable-key authorization.
privateRevocableKey Auth-policy material, rotatable via key rotation.
viewingPrivateKey Decrypts incoming note payloads. An attacker who obtains it can see your incoming receive history, but spending is unaffected.
viewingPublicKey The X25519 public key derived from viewingPrivateKey. It is safe to publish on-chain (see register viewing key).

Constraints & limits

  • You must sign the exact EIP-712 payload the SDK's deriveKeysFromSignature expects. The canonical reference is buildSecretDerivationPayload in v2-monorepo/apps/sample/src/keystore/secretDerivationPayload.ts, which is kept outside the SDK by design.
  • The keys are wallet-bound: the same wallet always produces the same keys, while a different wallet produces a different identity with different notes and a different viewing scope.
  • Keep the derived keys on the client. Caching them in sessionStorage spares a browser session from re-prompting the wallet, and you can always drop the cache and re-derive, since the same signature reproduces the same keys. Don't send them to a remote server.

What it unlocks next

How to use it

import { CryptoService, KeystoreManager } from "@privacy-pools-v2/sdk";
// Reference payload builder lives in the sample app, not the SDK (see above):
import { buildSecretDerivationPayload } from "./keystore/secretDerivationPayload.js";

const payload = buildSecretDerivationPayload(ownerAddress);
const signature = await walletClient.signTypedData({
account: ownerAddress,
domain: payload.domain,
types: { SecretDerivation: payload.types.SecretDerivation },
primaryType: payload.primaryType,
message: payload.message,
});

const keystoreManager = KeystoreManager.fromSignature(
{
signature,
signerAddress: ownerAddress,
addressHash: payload.message.addressHash,
revocableKeyIndex: "0x0",
},
{ cryptoService: new CryptoService() },
);

const privateNullifyingKey = keystoreManager.getPrivateNullifyingKey();
const privateRevocableKey = keystoreManager.getPrivateRevocableKey();
const { privateKey: viewingPrivateKey, publicKey: viewingPublicKey } =
keystoreManager.getViewingKeyPair();

Recover on a new device

Because the keys come only from your wallet signature, recovering an account on a new device (or after a sessionStorage wipe) is just running this derivation again. There is no extra state to back up. Two things differ from a first-time derivation:

  • Rotation index: if the account ever rotated its revocable key, re-derive at its current revocableKeyIndex rather than 0x0. Reuse the index your app persisted, or recover it on-chain with session.discoverRevocableKeyIndex(...), which scans candidate indices against the stored auth digest.
  • Notes: the keys alone don't carry your note history. After rebuilding the session, call discoverNotes() to repopulate it from chain.

The same wallet on a phone and a laptop derives the same keys and sees the same notes, so multi-device access needs no syncing beyond that re-discovery.

Behind the scenes

Primitives EIP-712 typed-data signing, two-stage HKDF-SHA256 derivation, and X25519 ECDH public-key derivation
SDK source v2-monorepo/packages/sdk/src/services/CryptoService.ts (deriveKeysFromSignature)
Where Fully client-side. The wallet signature happens via your provider; the SDK requires a standard 65-byte ECDSA signature