Skip to main content
Version: develop

Accounts & Runtimes

Account interfaces

InterfaceCapabilitiesProperties
ZkpReadAccountdecrypt(ciphertext)zkpAddress
ZkpProvingAccountAbove + prove(request)
ZkpSigningAccountsignControllerAuth(typedData)controllerAddress
ZkpAccountAll of the abovezkpAddress, controllerAddress

ZkpAccount extends both ZkpProvingAccount and ZkpSigningAccount.

Account factories

createReadAccount

import { createReadAccount } from '@cardinal-cryptography/core'

const account = createReadAccount({
esk: EncryptionSecretKey,
solver: Solver,
})
// Returns: ZkpReadAccount

createAccount

Creates a full account with a local controller spending key (CSK):

import { createAccount } from '@cardinal-cryptography/core'

const account = createAccount({
esk: EncryptionSecretKey,
solver: Solver,
prover: Prover,
csk: ControllerSpendingKey,
})
// Returns: ZkpAccount

createAccountWithSigner

Creates a full account with an external signer (MetaMask, hardware wallet):

import { createAccountWithSigner } from '@cardinal-cryptography/core'

const account = createAccountWithSigner({
esk: EncryptionSecretKey,
solver: Solver,
prover: Prover,
signer: ExternalSigner,
})
// Returns: ZkpAccount

ExternalSigner interface

Any object with these two methods:

interface ExternalSigner {
getAddress(): EvmAddress
signTypedData(args: {
domain: TypedDataToSign['domain']
types: TypedDataToSign['types']
primaryType: string
message: Record<string, unknown>
}): Promise<HexSignature>
}

viem's WalletClient does not satisfy this interface directly — wrap it in a small { getAddress, signTypedData } adapter. The linking helpers below need no adapter; they call walletClient.signTypedData themselves — see Wallet Linking.

Wallet linking helpers

Exported from @cardinal-cryptography/core (and @cardinal-cryptography/sdk):

FunctionDescription
buildZkStablesLinkingSignRequest(wallet, app)EIP-712 payload for signTypedData
signZkStablesLinkingMessage(walletClient, { app, account? })Sign linking message via viem; rejects non-EOA signatures
createAccountFromLinkedWallet(runtime, walletClient, { app, account? })Sign, derive ESK, build account with the wallet as controller
signerFromWalletClient(walletClient, address)Adapt a WalletClient into the controller signer

app is required (use ZKSTABLES_DEFAULT_APP for the default) and permanent — it is the EIP-712 domain name and determines the derived account, so choose once per app and never change it. See Wallet Linking for what it controls and why.

Constants: ZKSTABLES_DEFAULT_APP, ZKSTABLES_LINKING_DISCLAIMER, ZKSTABLES_LINKING_VERSION, ZKSTABLES_LINKING_TYPES.

Runtime interfaces

ReadRuntime

Solver only. For decryption without proving.

interface ReadRuntime {
readonly solver: Solver

createReadAccount(esk): ZkpReadAccount
createReadAccountFromMnemonic(mnemonic, accountIndex?): ZkpReadAccount
createReadAccountFromSeed(seed, accountIndex?): ZkpReadAccount

destroy(): void
}

ZkpRuntime

Solver + prover. Full capability.

interface ZkpRuntime extends ReadRuntime {
readonly prover: Prover

createAccount(esk, signer): ZkpAccount
createAccountFromMnemonic(mnemonic, accountIndex?): ZkpAccount
createAccountFromSeed(seed, accountIndex?): ZkpAccount

prewarmProver(): Promise<void>
destroy(): void
}

Runtime factories

Node (ergonomic — preferred)

@cardinal-cryptography/sdk factories build the solver and prover internally:

import { createReadRuntime, createRuntime } from '@cardinal-cryptography/sdk'

const readOnly = createReadRuntime() // solver only
const full = createRuntime() // solver + prover (threads auto-detected)

Both accept an optional config:

const readOnly = createReadRuntime({ solver: { tiers: [...] } })
const full = createRuntime({ solver: { tiers: [...] }, prover: { threads: 4, initTimeoutMs: 15_000 } })

prover.threads sets the bb.js worker count; prover.initTimeoutMs (default 15_000) bounds backend initialization so a failed worker / WASM load fails loudly instead of hanging forever.

Core (low-level, platform-agnostic)

@cardinal-cryptography/core exposes assembleRuntime / assembleReadRuntime for callers who supply their own pre-built backends (useful in tests or custom environments):

import { assembleReadRuntime, assembleRuntime } from '@cardinal-cryptography/core'

const readOnly = assembleReadRuntime(solver)
const full = assembleRuntime(solver, prover)

Lifecycle

  1. Create a runtime (backends are built internally)
  2. Create accounts from the runtime (multiple accounts can share one runtime)
  3. Use accounts with clients
  4. Destroy the runtime when done (frees WASM resources)
const runtime = createRuntime()
const account = runtime.createAccountFromMnemonic('...')

// ... use account with clients ...

runtime.destroy() // Frees solver + prover WASM