Skip to main content

ADR-016: Functional SDK Public API

StatusAccepted
Date2026-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 CustomSource pattern, ZkpAccount holds key material internally; prover and solver are captured in closures, not passed per-call.
  • **zkpAccount hoisted, token explicit.** Identity is hoisted; token varies per operation.
  • Proof kinds tagged at runtime. Prover.prove takes 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 CustomSourceLocalAccount 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 a ZkpReadAccount for decrypt-only reads.
  • createFullClient — binds a ZkpAccount for 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.