ADR-017: Platform Packages and Runtime Architecture
| Status | Accepted |
| Date | 2026-04-02 |
Context
ADR-016 defines the SDK's public API as stateless viem extensions and ZkpAccount types. Creating a ZkpAccount requires a Prover and a Solver — and ADR-016 deliberately leaves their creation and ownership open. This ADR defines the package split, the runtime/client layering that fills that gap, and the cross-package versioning rules.
Three forces shape the decision:
- Platform divergence. Prover and solver implementations differ fundamentally per platform: Node and browser use the WASM stack (bb.js + noir) but with different builds and SAB-detection, React Native uses native provers via Rust FFI / JSI with no dependency overlap.
- Bundle size. A portfolio dashboard only needs the solver. The prover — circuit artifacts and a couple of MB of WASM glue — must be reachable but tree-shakeable, and the heaviest pieces (per-kind circuit JSONs) lazy-loaded.
- Singleton lifecycle. Prover and solver are expensive singletons (WASM compilation, threads, circuit artifacts) that must be shared across accounts and explicitly torn down. The functional viem-extension API has no obvious place for that ownership.
Package structure
WASM-backed targets (Node, browser) share one package; mobile is its own.
@cardinal-cryptography/core — Platform-agnostic. viem extensions, types,
ZkpAccount / ZkpReadAccount,
Prover / Solver interfaces,
createAccount(), client factories, ABIs.
Depends only on viem. Zero heavy deps.
@cardinal-cryptography/sdk — WASM solver + WASM prover. One package, conditional
exports route per target:
node → DlogSolver (Node WASM) + NoirProver
browser → DlogSolver (browser WASM, multi-thread
if crossOriginIsolated) + NoirProver
Peers: @aztec/bb.js, @noir-lang/*.
@zk-privacy/mobile — Native solver + native prover (Rust FFI / JSI).
Separate package — zero overlap with the WASM
stack means co-shipping would force every WASM
consumer to pull native peers.
Each package has a single root entry. @cardinal-cryptography/sdk honors the node condition for Node and browser for bundlers; user lists one dependency, the runtime or bundler picks the entry. Both sdk and mobile re-export @cardinal-cryptography/core, so most apps depend on one package. Library authors depend on @cardinal-cryptography/core directly to stay platform-agnostic.
Runtime
An app creates one runtime per process. ReadRuntime owns the solver and exposes read-account factories (decrypt-only). ZkpRuntime extends ReadRuntime with the prover and full-account factories (prove + sign). Pick one at startup; you don't upgrade between them.
Each platform package (@cardinal-cryptography/sdk, @zk-privacy/mobile) exposes createReadRuntime and createRuntime. Config is platform-specific (asset bundling, native modules, thread budgets) and beyond the scope of this ADR.
Two layers
┌─────────────────────────────────────────────────────┐
│ Client factories (createFullClient, etc.) │ ← @cardinal-cryptography/core
│ Stateless sugar, no state │
└──────────────────┬──────────────────────────────────┘
│ takes ZkpAccount
┌──────────────────┴──────────────────────────────────┐
│ Runtime (ReadRuntime / ZkpRuntime) │ ← @cardinal-cryptography/sdk, @zk-privacy/mobile
│ Creates accounts, owns prover + solver singletons. │
└─────────────────────────────────────────────────────┘
The runtime owns lifecycle (prover, solver, WASM memory, threads). The client is a stateless viem client — no destroy(), no singletons. Multiple clients can share one runtime; lifecycle is explicit (one runtime.destroy() cleans up everything).
Versioning
@cardinal-cryptography/core owns the major.minor version for the entire SDK. Platform packages share core's major.minor and bump patch independently. Managed via Changesets.
- A major or minor bump in core cascades to all platform packages.
- A platform-only change (e.g. a WASM loading fix) bumps only that package's patch version.
- Platform packages never bump major or minor on their own.
The major.minor identifies the SDK generation (which core API surface); the patch identifies how many platform-specific fixes have shipped on top. Two platform packages at the same major.minor are guaranteed to share the same core types and extensions.
Why not fully independent versions? ethers.js v5 used independent versioning across sub-packages, which led to mismatched installs and inflated bundles. Core-owns-major.minor prevents the same drift without forcing a mono-package.
Why not full lockstep? A WASM-side fix in @cardinal-cryptography/sdk shouldn't ship a no-op release of @zk-privacy/mobile and vice versa. Lockstep creates noise and meaningless changelogs.
Consequences
Easier:
- Read-only apps ship minimal bundles — no prover, no bb.js, no noir.
- A new platform means one package implementing
ReadRuntime/ZkpRuntime. Core and viem extensions are untouched. - Tests mock
ProverandSolverdirectly; no runtime needed. - One runtime per app, one
destroy().
Harder:
- Two concepts: runtime (lifecycle) and client (extensions). Client factories collapse the common case to a two-step pattern.
- Multiple packages to publish (mitigated by core-bump cascades and CI).
Alternatives considered
Single mono-package across all platforms. Works only when every target shares a dependency tree. Our WASM targets (Node, browser) do — we ship them together in @cardinal-cryptography/sdk via conditional exports. Mobile (Rust FFI / JSI) has zero overlap with the WASM stack; co-shipping would force every WASM consumer to install native peers (or to silently skip them and risk install-time confusion). Splitting mobile into its own package keeps each consumer's dependency footprint matched to their platform.