ADR-016: Functional SDK Public API
| Status | Accepted |
| Date | 2026-04-02 |
Context
Our protocol adds an encrypted-balance layer on top of EVM tokens. Reading balances, checking registration, and submitting transactions work differently from standard ERC-20 — but they operate at the same abstraction level as viem: stateless reads and signed writes against on-chain contracts.
This ADR defines the encrypted-balance interface as a set of viem extend() actions:
const runtime = createRuntime()
const zkpAccount = runtime.createAccountFromMnemonic(mnemonic)
const client = createWalletClient({ account: '0x...', chain: base, transport: custom(window.ethereum!) })
.extend(publicActions)
.extend(zkpPublicActions())
.extend(zkpEvmActions())
.extend(zkpPreparedActions())
.extend(zkpWalletActions({ zkpAccount }))
await client.sendRegisterEpk()
await client.sendEncryptedTransfer({ token: 'USDC', amount: 100n, to: 'zk1...' })
A higher wallet/identity layer (key derivation, seed management, caching, storage, recovery) is out of scope.
Design principles
- Same layer as viem. Encrypted reads/writes are the same class of operation as token-aware viem usage.
- Stateless. No caching, no singletons, no side effects. Higher layers own state.
- Progressive capability. Use only what you need — read-only indexing through full encrypted transfers.
- Account encapsulates cryptography. Following viem's
CustomSourcepattern,ZkpAccountholds key material internally; prover and solver are captured in closures, not passed per-call. **zkpAccounthoisted,tokenexplicit.** Identity is hoisted; token varies per operation.- Proof kinds tagged at runtime.
Prover.provetakes a discriminated{ kind, …witness }input. The SDK ships kinds for every protocol flow; custom kinds are pluggable on the prover.
Network layer
zkpPublicActions
Stateless chain reads. Returns raw ciphertexts and registration state; decryption belongs to a higher layer (see ZkpAccount). ERC-20 reads and publicTransfer are not wrapped — viem handles those natively. The optional erc3009Actions extension wraps EIP-3009 transferWithAuthorization for plain ERC-20.
Token configuration
Tokens are referenced by name ('USDC', 'EURC'), not address. The SDK ships default deployments per chain and resolves names internally; testnets pass overrides via the extension config. Token names are type-safe — passing an unconfigured token is a TypeScript error and a runtime throw.
Transaction submission
A network-layer concern, not wallet-layer policy. Default posture is bundler-first with capability duck-typing: paymaster when available (sponsorship or token payment), else ERC-4337 user op via bundler, else classic eth_sendTransaction. All write extensions share one submission helper — callers see a uniform Promise<txHash> regardless of routing.
EVM actions
Actions performed by a public EVM account (EOA) that interact with encrypted accounts: public-to-encrypted transfer and toggling auto-encrypt routing for an EVM address. Exposed via zkpEvmActions. Standalone — no ZkpAccount needed.
ZkpAccount
The network layer returns raw ciphertexts and accepts pre-built proofs. Decrypting balances or generating proofs requires a ZkpAccount. Following viem's CustomSource → LocalAccount pattern, the account encapsulates key-dependent operations — spending key, prover, and solver never appear in the type.
Capability split
Decrypt, prove, and sign are split into separate interfaces so each capability can be granted independently — e.g. a proving service holds the ESK while custody (Fireblocks, Safe, etc.) holds the CSK and signs (ADR-014).
interface ZkpReadAccount {
zkpAddress: `zk1${string}`
decrypt(ct: ElGamalCiphertext): Promise<bigint>
}
interface ZkpProvingAccount extends ZkpReadAccount {
prove(req: ProofRequest): Promise<ProofResult>
}
interface ZkpSigningAccount {
controllerAddress: EvmAddress
signControllerAuth(typed: TypedDataToSign): Promise<HexSignature>
}
interface ZkpAccount extends ZkpProvingAccount, ZkpSigningAccount {}
Account creation
Factory functions capture dependencies in closures. ESK and CSK are independent siblings of the same seed (per ADR-014 + ADR-015); CSK can be local (self-custody) or delegated to an external signer (hardware, browser wallet, custody).
function createReadAccount(opts: { esk; solver }): ZkpReadAccount
function createAccount(opts: { esk; solver; prover; csk }): ZkpAccount
function createAccountWithSigner(opts: { esk; solver; prover; signer: ExternalSigner }): ZkpAccount
ZK-aware actions
Compose network-layer actions with ZkpAccount capabilities. Like viem's WalletClient hoisting account, these hoist zkpAccount (overridable per-call).
zkpDecryptActions
Decrypt encrypted balances and balance history. Requires a ZkpReadAccount.
zkpWalletActions
Send encrypted transfers, transfer to public, register an EPK. Requires a ZkpAccount. Each flow orchestrates network reads, account decrypt/prove, and prepared submission.
Client factories
Stateless sugar over the .extend() chains. Live in @cardinal-cryptography/core (no platform deps); take a ZkpAccount, not a runtime:
createEvmClient— binds an EVM-side EOA for public-to-encrypted transfer and auto-encrypt toggle. No ZK proofs.createDecryptClient— binds aZkpReadAccountfor decrypt-only reads.createFullClient— binds aZkpAccountfor full ZK-side actions (encrypted transfers, register-EPK).
A consumer needing both EVM-side and ZK-side surfaces constructs both clients. Token configuration is not part of client factories — it belongs at the extension level. Apps that need custom token addresses drop down to manual extension composition.
Alternatives considered
All-in-one client (previous ADR-016 draft). Combined network IO, key management, proof generation, caching, and account types. Simpler for end users but impossible to use the network layer independently — every test needed a prover and solver.
Custom client types instead of viem extend. An EncryptedBalanceClient wrapping viem internally. Loses interop with other viem extensions (permissionless.js, etc.).
Prover/solver per-call instead of captured in account. Exposes implementation details and prevents alternative backends (custodial, hardware). CustomSource is strictly better.
Single orchestration extension. Would force portfolio viewers to depend on a prover. The decrypt/wallet split enables lightweight read-only use.
Hoisting token on extensions. Token is context (per-operation), not identity (set once). Explicit per-call keeps the client token-agnostic.