Architecture
The SDK has four layers. You primarily interact with the Client layer -- behind the scenes, it calls Account (which holds your keys), which uses Runtime (which runs the proving and decryption services), which talks to Chain (standard EVM).
Runtime (decryption + proving services)
|
Account (keys in closures: decrypt, prove, sign)
|
Client (viem client + action extensions)
|
Chain (EVM JSON-RPC)
Runtime
Provides the computationally heavy services: decryption and ZK proving. Both are WASM modules that lazy-load when first used. Created once, shared across accounts.
- ReadRuntime -- decryption service only
- ZkpRuntime -- decryption + proving services
@cardinal-cryptography/sdk provides the runtime factories. The same package serves Node and browser via conditional exports, each entry configured with the appropriate WASM build.
Account
Holds cryptographic keys in closures. Never exposes private keys.
- ZkpReadAccount -- can decrypt ciphertexts
- ZkpAccount -- can decrypt, generate ZK proofs, and sign controller authorizations
Accounts are created from a runtime. Multiple accounts can share one runtime.
Client
A viem client extended with ZKP action methods, scoped to one chain and one account. Clients use a bundler and paymaster by default -- gas is sponsored so users don't need ETH.
Best practice: create the client once with a hoisted account and reuse it across your application.
Three convenience factories:
| Factory | What it's for | Bundler-backed |
|---|---|---|
createEvmClient | Public account ops (register, send to encrypted, auto-encrypt) | Yes |
createDecryptClient | Read + decrypt balances and history | No (read-only) |
createFullClient | Everything: decrypt + encrypted transfers | Yes |
Bundler + paymaster (the default)
Every write goes through the SharedAccount via an ERC-4337 bundler. Two consequences:
- No ETH for users. A paymaster pays gas on every UserOp. End users hold tokens; they never need a separate gas balance.
- No on-chain link between your ZKP account and the gas-payer. Every UserOp lands with
msg.sender = SharedAccount. The controller key never holds funds, never appears astx.origin. The paymaster pays from its own wallet — never from yours. Authorization is in the ZK proof + EIP-712 controller signature, not in who submitted the transaction.
This is the default for createEvmClient and createFullClient. See Gas Sponsorship for configuration.
Manual WalletClient path
Each action extension (zkpPublicActions, zkpEvmActions, zkpWalletActions, etc.) is a plain viem extension. You can extend a regular WalletClient and submit ZKP operations directly with a funded EOA -- bypassing the bundler. You forfeit gas sponsorship and the unlinkability property above. There's no convenience factory for it; assemble it yourself if you have a reason to.
Chain
Standard EVM JSON-RPC. The SDK reads from and writes to encrypted balance token contracts deployed on-chain.
Why this architecture?
The decryption and proving services are WASM modules -- loading them has a real cost, especially in browsers:
- EVM-only operations (register, send to encrypted address) load no WASM at all -- pure RPC calls
- Decrypt-only loads only the decryption WASM
- Full loads both decryption and proving WASM
By separating Runtime → Account → Client, you control exactly what gets loaded. A portfolio dashboard doesn't need the prover. An onboarding page doesn't need either.
The account layer also provides security isolation: keys live in closures, separate from the network-facing client. The client never sees your private keys.