Skip to main content
Version: develop

React Hooks

A wagmi-style provider and useZkStables() hook for binding a ZKP account to React tree state. Same surface on web and React Native — only the import path differs.

The provider owns one ZkpRuntime and one viem PublicClient for the tree, exposes a session state machine (idle / connecting / connected / error), and gives you connect / disconnect actions. When connected, you get a ready-to-use FullClient — the same one createFullClient returns — already bound to the account.

Mount the provider

Wrap the part of your tree that needs ZKP access. chain is the only required field — bundler, paymaster, tokens, and SharedAccount address resolve from baked-in deployment defaults when the chain is supported (see Live Deployments).

// Web
import { ZkStablesProvider } from '@cardinal-cryptography/sdk/react'
import { baseSepolia } from 'viem/chains'

export function App({ children }) {
return (
<ZkStablesProvider config={{ chain: baseSepolia }}>
{children}
</ZkStablesProvider>
)
}
// React Native — identical API, different package
import { ZkStablesProvider } from '@cardinal-cryptography/sdk-react-native/react'
import { baseSepolia } from 'viem/chains'

export function App({ children }) {
return (
<ZkStablesProvider config={{ chain: baseSepolia, prewarm: true }}>
{children}
</ZkStablesProvider>
)
}

prewarm: true calls runtime.prewarmProver() on mount so the first proof doesn't pay the one-time prover warmup (loading circuits + initializing the prover; on React Native also the SRS/VK first-use cost). Recommended on any platform where users will prove.

The provider snapshots chain, transport, runtime, prewarm, and the bundler config at mount; later changes are ignored. To swap any of them, remount: <ZkStablesProvider key={chain.id} config={…}>.

Connect

import { useZkStables } from '@cardinal-cryptography/sdk/react'

export function Connect() {
const { status, error, fromMnemonic } = useZkStables()

if (status === 'connecting') return <p>Connecting…</p>
if (status === 'error') return <p>Failed: {error.message}</p>
if (status === 'connected') return <p>Connected.</p>

return (
<button onClick={() => fromMnemonic('abandon abandon ... about')}>
Connect
</button>
)
}

fromMnemonic derives the account, builds the bundler-extended client, and atomically transitions idle → connecting → connected. If the client build throws, the session lands in error with the original Error preserved. Calling fromMnemonic again (or disconnect) mid-flight cancels the pending resolve. Each connect method resolves to a booleantrue when that attempt won and connected, false when it was superseded by a later call or landed in error — so you can gate post-connect UI on if (await fromMnemonic(...)).

import { ZKSTABLES_DEFAULT_APP } from '@cardinal-cryptography/sdk'

const { fromLinkedWallet } = useZkStables()

await fromLinkedWallet(walletClient, { app: ZKSTABLES_DEFAULT_APP })
// or: fromLinkedWallet(probeClient, { app: ZKSTABLES_DEFAULT_APP, account: address })

fromLinkedWallet signs the canonical wallet linking message via core, derives the account, and installs the session. The signing prompt is part of the lifecycle: status is connecting while the wallet popup is open, and a rejected signature lands in error. Same options as createAccountFromLinkedWallet: app is required and permanent (use ZKSTABLES_DEFAULT_APP for the default), with an optional account fallback.

Four setters, one per account-creation mode:

SetterUse
fromMnemonic(mnemonic, index?)BIP-39 mnemonic. Optional index for HD-style derivation (default 0).
fromLinkedWallet(walletClient, { app, account? })MetaMask / viem EOA — sign linking message + connect (preferred over manual fromSeed). app required.
fromSeed(seed, index?)Raw seed bytes or hex signature when you already have the linking signature.
fromEsk(esk, signer)Pre-derived ESK + external ExternalSigner for delegated controller signing (MetaMask, Ledger).

Each setter resolves true when its connect landed, false when it failed or was superseded mid-flight by a newer setter or disconnect.

See Account Creation for which to pick. For MetaMask + linking, see Wallet Linking.

Use the client

client is null until status === 'connected'. Gate on status (or use RequireConnected) before calling action methods.

import { useZkStables } from '@cardinal-cryptography/sdk/react'

export function Send() {
const { client, status } = useZkStables()

async function send() {
if (status !== 'connected') return
await client.sendEncryptedTransfer({
token: 'zkUSD',
amount: 50n,
to: 'zk1recipient…',
})
}

return <button onClick={send}>Send 50 zkUSD</button>
}

client is bundler-extended — the same surface documented in Encrypted Transfers and Read Balances.

useSwapRate

The polling half of the swap estimation recipe: the current rate + fee, refetched on an interval. Works before a session connects (it needs only the chain). Derive amounts per keystroke with calculateSwapAmounts — synchronous, no network call.

import { calculateSwapAmounts } from '@cardinal-cryptography/sdk'
import { useSwapRate } from '@cardinal-cryptography/sdk/react'

export function SwapForm() {
const { rate, isLoading } = useSwapRate({ tokenIn: 'zkUSD', tokenOut: 'zkEUR' })
const [amountIn, setAmountIn] = useState(0n)
const estimate = rate && amountIn > 0n
? calculateSwapAmounts({ rateQuote: rate, amountIn })
: null
// estimate.amountOut / estimate.feeAmount drive the form display
}

Params: { tokenIn, tokenOut, refetchIntervalMs? (default 15s, the rate provider's quote cadence), rates? }rates defaults to the chain's canonical rate provider from the deployment manifest. Returns { rate, error, isLoading }; the last rate is kept through transient refetch failures, with error set until the next success. A stale display never produces a stale order — quoteSwap re-prices internally.

RequireConnected

Drop-in gate so child components don't repeat the status === 'connected' check. Children render only when connected.

import { RequireConnected } from '@cardinal-cryptography/sdk/react'

<RequireConnected fallback={<Connect />}>
<Balance />
<Send />
</RequireConnected>

Pass a render function for a narrowed context (account and client non-null):

<RequireConnected fallback={<Connect />}>
{(zk) => <Send client={zk.client} account={zk.account} />}
</RequireConnected>

Or use useZkStablesConnected() inside a child that is already gated — throws if not connected.

Disconnect

const { disconnect } = useZkStables()
disconnect() // → status: 'idle'

disconnect resets the session and cancels any in-flight connect. The underlying runtime is preserved — you can call fromMnemonic again without rebuilding it.

Multichain

The provider snapshots chain at mount. To switch chains, remount with a fresh key:

<ZkStablesProvider key={chain.id} config={{ chain }}>
{children}
</ZkStablesProvider>

This tears down the old provider (and its viem client) and builds a fresh one. The session is reset; the caller is responsible for re-connecting.

Persistence

The provider is in-memory only — refreshing the page resets status to 'idle'. Persist the mnemonic / seed yourself in platform storage and re-connect on mount.

// React Native — Expo SecureStore
import { useEffect } from 'react'
import * as SecureStore from 'expo-secure-store'
import { useZkStables } from '@cardinal-cryptography/sdk-react-native/react'

function useAutoConnect() {
const { fromMnemonic, status } = useZkStables()
useEffect(() => {
if (status !== 'idle') return
SecureStore.getItemAsync('zkp-mnemonic').then((m) => {
if (m) void fromMnemonic(m)
})
}, [fromMnemonic, status])
}

The web shape is the same with localStorage.getItem in place of SecureStore.getItemAsync. Don't put a mnemonic in AsyncStorage (RN) or plain cookies (web) — both are plaintext. Use expo-secure-store / react-native-keychain on RN, an encrypted browser store on web, or skip persistence entirely.

ZkStablesConfig

FieldRequiredDefaultNotes
chainyesviem Chain. Determines deployment defaults.
transportnohttp()viem Transport for the internal PublicClient.
bundlerUrlnofrom chain defaultsERC-4337 bundler endpoint.
paymasternofrom chain defaults{ paymasterUrl, signPartnerContext }. See Gas Sponsorship.
sharedAccountAddressnofrom chain defaultsSharedAccount contract address.
tokensnofrom chain defaultsCustom TokenMap override.
runtimenobuilt via createRuntime({})Inject a pre-built runtime (tests, sharing across providers). Provider-built runtimes are destroyed on unmount in prod; injected ones are not.
prewarmnofalseCall runtime.prewarmProver() on mount to pay the first-proof warmup up front. Recommended on any platform where users prove.
threadsnohost cores (4 if unavailable)bb.js worker count for browser proving. Browsers need cross-origin isolation (COOP/COEP) to use >1; without it proving falls back to single-thread (the prover logs a warning). Ignored on React Native (native proving) and when runtime is supplied.

Anything that createFullClient accepts in its { chain } branch (minus client and zkpAccount, which the provider supplies) is passed through verbatim — the config shape stays in lock-step with the underlying factory.

useZkStables() return

useZkStables() flattens the session onto the context value. The shape is a discriminated union on status:

FieldTypeNotes
runtimeZkpRuntimeStable across the provider's lifetime.
viemClientPublicClientThe provider's internal chain reader. Stable.
chainIdnumberFrom config.chain.id.
tokensTokenMapResolved from config.tokens or chain defaults.
status'idle' | 'connecting' | 'connected' | 'error'Session state.
accountZkpAccount | nullNon-null iff status === 'connected'.
clientFullClient | nullNon-null iff status === 'connected'.
errorError | nullNon-null iff status === 'error'.
fromMnemonic(mnemonic, index?) => Promise<boolean>Resolves true if this attempt connected, false if it was superseded or errored.
fromLinkedWallet(walletClient, { app, account? }) => Promise<boolean>Link EOA + connect.
fromSeed(seed, index?) => Promise<boolean>As above.
fromEsk(esk, signer) => Promise<boolean>As above.
disconnect() => voidReset to idle.

Calling useZkStables() outside a <ZkStablesProvider> throws.