Migrate from v2 to v3
Step-by-step guide to upgrade an app from @zama-fhe/sdk 2.x to 3.x — config factory, relayer factories, permits, operators, encrypt/decrypt glossary.
This guide upgrades an application that uses @zama-fhe/sdk and @zama-fhe/react-sdk from 2.5.0 (the last 2.x release) to the 3.x line. Each step has an explicit Before (2.x) / After (3.x) pair and a find/replace rule, so it works whether you migrate by hand or hand it to an AI coding agent.
The happy path: for most apps it's Step 1 (config) plus mechanical renames — the high-level Token flow API keeps its signatures. Only three surfaces actually moved: approve → operators (Step 4), token delegation → sdk.delegations.* (Step 3), and balanceOf now takes the holder (Step 4).
Before you start. This assumes a working app on @zama-fhe/sdk / @zama-fhe/react-sdk 2.5.0 (upgrade to 2.5.0 first if you're below it), a clean git tree so you can review the migration as a diff, and Node 22+. The API here is complete as of 3.1.x; the 3.0 major bump was an on-chain wrapper/registry upgrade (Step 6), not a TypeScript change. It's a code-only migration — to roll back, discard the diff and reinstall @^2 (the deployed Step 6 contract upgrade isn't something your app reverts).
Migrate with an AI coding agent
This guide is built to be executed by an AI coding agent (Claude Code, Cursor, Copilot, …). The fastest path is to point an agent at your repository with the prompt below. Read the rest of this page if you'd rather migrate by hand — or to review what the agent is doing.
Agents trained before this SDK existed routinely confuse @zama-fhe/sdk (this high-level SDK) with the legacy low-level @zama-fhe/relayer-sdk (createInstance / initSDK). The prompt forbids that — keep the instruction to treat this guide as the source of truth.
Upgrade this repository from @zama-fhe/sdk and @zama-fhe/react-sdk v2.x to v3.x.
SOURCE OF TRUTH — follow it exactly:
https://docs.zama.org/protocol/sdk/migration/migrate-v2-to-v3.md
Rules:
1. Fetch and read that guide BEFORE doing anything. It is authoritative. Do NOT
rely on prior knowledge of the "Zama SDK": this is the high-level
@zama-fhe/sdk, NOT the legacy @zama-fhe/relayer-sdk (createInstance/initSDK).
For any symbol you're unsure about, use the guide's symbol-mapping table; for
anything it doesn't cover, fetch
https://raw.githubusercontent.com/zama-ai/sdk/main/llms.txt and follow its links.
2. First print a short PLAN: list every file importing @zama-fhe/sdk or
@zama-fhe/react-sdk and which guide Steps apply to each. Then proceed.
3. Apply the Steps IN ORDER, starting with Step 1 (configuration) — it unblocks
the rest. Use the symbol table for renames. Respect the per-symbol notes: the
React hook calling convention is MIXED — see the Step 4 convention table (most
hooks are positional `useX(address)`, the rest take `{ address }`, and the old
`{ tokenAddress }` field is gone), and several signatures changed (e.g.
balanceOf(owner), isOperator(holder, spender)).
4. Do NOT change app logic or unrelated code. The high-level Token flow API
(shield, confidentialTransfer, unshield, …) is unchanged except where the
guide says otherwise.
5. Bump the @zama-fhe/* deps to ^3 using this repo's package manager.
6. VALIDATE: run the type checker and the guide's final leftover-symbol `rg`
sweep; fix until both are clean. Inspect the false-positive cases the guide
calls out (.chainId on viem/EIP-1193 objects, token.approve on the underlying
ERC-20, tokenAddress in your own variable names) instead of blind-replacing.
7. Show the result as a diff and flag anything ambiguous for human review.No repo access (a plain chat assistant)? Paste this page as the source of truth, then the contents of your SDK-using files, and ask for the v3 rewrite of each under the same rules.
Step 0 — Install
Symbol mapping (quick reference)
The tables below are the authoritative list of renames. Skim for the symbol you need and jump to the cited Step, or read Steps 1→7 in order for a full migration.
@zama-fhe/sdk (core)
@zama-fhe/sdk (core)new ZamaSDK({ relayer, signer, storage })
new ZamaSDK(createConfig({ chains, …client, relayers, storage }))
SepoliaConfig / MainnetConfig / HardhatConfig (from @zama-fhe/sdk)
sepolia / mainnet / hardhat (+ hoodi, anvil, ingenTestnet, bscTestnet) from @zama-fhe/sdk/chains
ViemSigner / EthersSigner (constructed)
pass publicClient/walletClient (or ethers provider/signer) to createConfig
CredentialsManagerConfig, Credentials*Event, StoredCredentials, StoredKeypair
Permission, StoredTransportKeyPair (+ permit events)
EncryptResult.handles (bytes)
EncryptResult.encryptedValues (hex; inputProof is now hex too)
UserDecryptParams, PublicDecryptResult, DelegatedUserDecryptParams, DecryptHandle
DecryptValuesParams, DecryptPublicValuesResult, DelegatedDecryptValuesParams, DecryptInput
KeypairType / Keypair; generateKeypair() / warmKeypair()
TransportKeyPair; generateTransportKeyPair() / warmTransportKeyPair()
relayer getPublicKey(); PublicKeyData
fetchFheEncryptionKeyBytes(); FheEncryptionKey
KeypairExpiredError / InvalidKeypairError
TransportKeyPairExpiredError / InvalidTransportKeyPairError
sdk.createReadonlyToken(addr); sdk.createToken(addr, wrapper?)
sdk.createToken(addr) (read/transfer); sdk.createWrappedToken(addr) (wrap/shield/unshield)
decodeUnwrappedFinalized, UnwrappedFinalizedEvent
decodeUnwrapFinalized, UnwrapFinalizedEvent
parseActivityFeed, ActivityItem, ActivityAmount, ActivityType
removed (activity feed dropped)
@zama-fhe/react-sdk (hooks)
@zama-fhe/react-sdk (hooks)<ZamaProvider relayer signer storage sessionStorage onEvent>
<ZamaProvider config={createConfig({…})}> (no sessionStorage)
token hooks taking { tokenAddress }
positional (address) or { address } — see the convention table
useUserDecrypt({ handles })
useDecryptValues(inputs) — renamed + arg shape change, see Step 5
usePublicKey, usePublicParams, useRequestZKProofVerification
removed — low-level key/proof hooks; the SDK handles these
Step 1 — Migrate the SDK configuration
This is the central change and affects every integration. The imperative "construct a Signer, construct a Relayer, pass them in" pattern is replaced by a single declarative createConfig({ chains, …client, relayers, storage }).
Why: v2 bound one relayer to one active chain (a second chain meant a second ZamaSDK); createConfig declares every chain and its relayer once, making multichain first-class.
Key shifts:
Chain presets move to
@zama-fhe/sdk/chainsand expose.id(not.chainId).You no longer construct
ViemSigner/EthersSigner/WagmiSigner. You pass the underlying clients (publicClient+walletClient, ethersprovider+signer, orwagmiConfig) tocreateConfig.Relayers become factories (
web()/node()) placed in arelayersmap keyed by each chain'sid. See Step 2.new ZamaSDK(config)/<ZamaProvider config={config}>take the object returned bycreateConfig.
Node / backend (viem)
If you also construct viem clients here (createPublicClient / createWalletClient), import viem's own sepolia under an alias (e.g. sepolia as viemSepolia) to avoid colliding with the sepolia preset from @zama-fhe/sdk/chains.
React (wagmi)
The react-sdk hooks use TanStack Query internally, so the host app must wrap <ZamaProvider> in a QueryClientProvider (a no-op change if your app already has one). This is the one piece of required wiring that is not captured by createConfig.
Notes:
The wagmi adapter creates the SDK signer/provider and subscribes to wagmi connection changes internally — no
useMemofor the relayer and nowalletKeyremount pattern needed.The 2.x
sessionStorageprop is removed. There is now a singlestorageoption (permits reuse it viapermitStorage, which defaults tostorage), so the separatenew IndexedDBStorage("SessionStore")is no longer required.All wiring (
relayer,signer,storage,onEvent) moves intocreateConfig;<ZamaProvider>takes a singleconfigprop.
Other adapters
Adapter
createConfig import
Clients to pass
viem
@zama-fhe/sdk/viem
publicClient, walletClient
ethers
@zama-fhe/sdk/ethers
provider, signer
wagmi (React)
@zama-fhe/react-sdk/wagmi
wagmiConfig
generic
@zama-fhe/sdk
provider, signer (GenericProvider/BaseSigner)
Step 2 — Migrate the relayer
Relayers are no longer classes you instantiate; they are factories placed in a relayers map keyed by each chain's id, inside createConfig.
new RelayerWeb({...})
web()
@zama-fhe/sdk/web
new RelayerNode({...})
node()
@zama-fhe/sdk/node
(new in v3)
cleartext()
@zama-fhe/sdk
cleartext() is new in v3 — the relayer for cleartext-mode chains (no FHE, KMS, or gateway): local dev (hardhat) and the cleartext testnets (hoodi, ingenTestnet, bscTestnet).
The getChainId / transports plumbing is gone: the network endpoint, relayer URL and auth are configured on the FheChain object (network, relayerUrl, auth) and the SDK resolves the right relayer per chain via RelayerDispatcher.
Imported the relayer config types directly? They followed the constructor → factory move: node() / web() / cleartext() return NodeRelayerConfig / WebRelayerConfig / CleartextRelayerConfig (all extend RelayerConfig). The relayer-sdk-level RelayerWebConfig / RelayerWebSecurityConfig are unchanged but now live under @zama-fhe/sdk/web.
Relayer auth (FheChain.auth). Still ApiKeyHeader | ApiKeyCookie | BearerToken. The Zama-hosted relayer requires ApiKeyHeader (sent as x-api-key; Bearer and cookie are rejected at the edge). Field names differ — ApiKeyHeader uses value, BearerToken uses token. See Relayer API keys.
Step 3 — Permits & delegated decryption
The "credentials/session" vocabulary is replaced by the permit model. A permit is a reusable EIP-712 signature granting your app decrypt rights for a set of contracts. See the Permit model concept page.
The mental model changed, not just the names:
2.x: your app held a session — a decrypt transport key pair (
useGenerateKeypair) plus per-contract credentials (the grants), all under one TTL. The key pair was chain-scoped, so switching chains threw it away.3.x: the two are decoupled — one identity transport key pair (owned by the SDK, shared across all chains, surviving chain switches) backs many independent permits (the grants). Adding a contract signs just an incremental permit rather than re-issuing the whole set, and the two revocation hooks split along that seam:
useRevokePermitsdrops grants but keeps the key pair,useClearCredentialsis a full logout.
In most apps you do not manage permits manually — decrypt hooks (useDecryptValues, useConfidentialBalance) trigger the permit signature automatically on first use. The explicit hooks are for gating that prompt and for revocation. The full hook renames are in the react-sdk symbol table.
Recommended pattern — gate any decrypt UI on useHasPermit so users don't get an unsolicited wallet popup on render:
SDK-level delegation moved off the Token instance onto the sdk.delegations namespace, and now takes the contract address explicitly. Only decryptBalanceAs stays on Token.
token.delegateDecryption({ delegateAddress })
sdk.delegations.delegateDecryption({ contractAddress: token.address, delegateAddress })
token.revokeDelegation({ delegateAddress })
sdk.delegations.revokeDelegation({ contractAddress: token.address, delegateAddress })
token.getDelegationExpiry({ delegatorAddress, … })
sdk.delegations.getExpiry({ contractAddress, delegatorAddress, delegateAddress })
(no Token-level status check)
sdk.delegations.isActive({ contractAddress, delegatorAddress, delegateAddress })
token.decryptBalanceAs(...)
unchanged — stays on Token
Step 4 — Approvals → operators
ERC-7984 uses an operator model instead of ERC-20-style allowances.
Why: approve borrowed ERC-20's verb, but a confidential balance is encrypted — there's no cleartext amount to cap. What you grant is time-boxed authority to move your tokens, which is what the on-chain setOperator (ERC-7984) does.
The write side is a pure rename — v2's token.approve() already called the on-chain setOperator, so behaviour is unchanged. The read side has one trap:
Reversed argument order. isApproved(spender, holder?) became isOperator(holder, spender). Both arguments are addresses, so a mechanical isApproved(a, b) → isOperator(a, b) rename compiles fine but silently swaps the two — a runtime bug with no type-checker signal.
The hook calling convention changed for every single-token hook
This is the easiest thing to under-migrate. The 2.x UseZamaConfig type ({ tokenAddress }) was removed. Every single-token hook either takes the token address positionally as its first argument, or keeps a config object with the field renamed tokenAddress → address. Don't assume a hook is unchanged just because its name is:
Positional — useX(address, options?)
useConfidentialSetOperator, useConfidentialTransferFrom, useApproveUnderlying, useUnshield, useUnshieldAll, useUnwrap, useUnwrapAll, useResumeUnshield, useFinalizeUnwrap, useDelegateDecryption, useRevokeDelegation, useDecryptBalanceAs, useToken, useWrappedToken
Config object — useX({ address, … }) (was { tokenAddress })
useShield, useConfidentialTransfer, useConfidentialBalance, useConfidentialBalances, useConfidentialIsOperator, useUnderlyingAllowance, useDelegationStatus
Read hooks that target a holder (useConfidentialBalance, useConfidentialBalances) now also require an explicit account.
Balance reads now take the holder explicitly
The 2.x convenience of defaulting balance reads to the connected account is gone — pass the holder address:
The React hook config changed to match: useConfidentialBalance({ tokenAddress }) becomes useConfidentialBalance({ address, account }) — address is the token, account is the holder to read.
Step 5 — Encrypt (hex) & decrypt glossary
Encrypt returns contract-ready hex
encrypt results are now hex strings ready to pass straight to a contract call — no more bytesToHex(...). The field handles is renamed encryptedValues (and inputProof is hex too), and extractEncryptedHandles(...) is removed — read result.encryptedValues directly.
sdk.signer may be undefined in 3.x. In 2.x the signer was passed at construction and always present; in 3.x it is undefined in read-only mode (no wallet connected) — hence the if (!sdk.signer) throw … guard above. Prefer that over asserting sdk.signer!, which only hides the undefined until it crashes at the call site. Reads never need the signer — use sdk.provider.
Decrypt glossary: handle → encryptedValue
handle → encryptedValueuseUserDecrypt was renamed useDecryptValues, and its argument changed: from an object { handles } to a positional array of { encryptedValue, contractAddress }. Result objects are keyed by encryptedValue (not handle). Reads also move from sdk.signer.readContract to sdk.provider.readContract — sdk.provider is always available, whereas in 2.x reads went through the signer.
Public (non-permit) decryption follows the same rename: usePublicDecrypt → useDecryptPublicValues. Both are mutations, so only the hook name changes — no permit is involved since the values are already publicly decryptable. The verb now says who reads — decryptValues (you, via your permit) vs decryptPublicValues (everyone).
Cache ownership changed. DecryptCache and applyDecryptedValues were public in 2.x; in 3.x the cache is internal and invalidates automatically (on permits.revokePermits(), permits.clear(), wallet account/chain change, and disconnect). There is no public API to populate or evict it — remove any 2.x logic that did, as there's no compile-time signal for its loss.
Key glossary: transport key pair & FHE encryption key
The glossary pass split two keys the old names blurred: the transport key pair (your locally-held decrypt keys) and the FHE encryption key (the network's input-encryption key). The core symbol table lists every rename — most apps touch none of them, since createConfig, the Token API, and the hooks manage keys internally.
Error codes are stable. Only the error class names and ZamaErrorCode enum keys changed (KeypairExpiredError → TransportKeyPairExpiredError, enum key KeypairExpired → TransportKeyPairExpired, etc.). The string code values (KEYPAIR_EXPIRED / INVALID_KEYPAIR) are unchanged, so matchZamaError and err.code === "KEYPAIR_EXPIRED" checks keep working.
Step 6 — Token / WrappedToken & upgraded contracts
ReadonlyTokenwas the read-only base in 2.x. In 3.x the base read/transfer class isToken, andWrappedTokenextends it with wrap/shield/unshield. Build them viasdk.createToken(addr)(read/transfer) orsdk.createWrappedToken(addr)— the 2.xsdk.createReadonlyToken(addr)is removed andsdk.createToken(addr, wrapper?)lost its second argument. The hookuseReadonlyToken→useWrappedToken.The wrapper/registry contracts were upgraded in 3.0. If you read registry results, check the new
isValidflag before using a wrapper:
Unwrap events/results now carry a new optional
unwrapRequestIdfield. If you decode unwrap events directly:decodeUnwrappedFinalized→decodeUnwrapFinalized(andUnwrappedFinalizedEvent→UnwrapFinalizedEvent), and the "started" decoder/event (decodeUnwrappedStarted/UnwrappedStartedEvent) were removed. If you only useunshield/unshieldAll/useUnshield, no change is needed.If you call
Token.finalizeUnwrapdirectly (an escape hatch — most apps useunshield), its argument changed fromburnAmountHandletounwrapRequestId: EncryptedValue(the id returned by the request phase).If you read decoded transfer events directly, the field
ConfidentialTransferEvent.encryptedAmountHandlewas renamedencryptedAmount(part of thehandle→encryptedValueglossary shift, Step 5).If you hardcoded
ERC7984_WRAPPER_INTERFACE_ID, its value changed; import the constant instead of inlining it.
Step 7 — Removed with no replacement
Activity feed is gone:
useActivityFeed,parseActivityFeed,ActivityItem,ActivityAmount,ActivityType,activityFeedQueryOptions,deriveActivityFeedLogsKey. It was a prebuilt transaction-history view — and what that history shows and how it's grouped is your app's call, not the SDK's. You keep every building block: decode events withdecodeOnChainEventand reveal amounts withdecryptValues/decryptPublicValues, or read from your own indexer.Utility exports
totalSupplyContract,matchAclRevert,sortByBlockNumberare removed.
Validation checklist
After applying the steps:
Run your type-checker (
pnpm typecheck,tsc --noEmit, …) — the SDK is strongly typed; most missed renames surface here.Search your codebase for leftover 2.x symbols:
A few atoms can still produce hits that don't need migrating — inspect rather than blind-replace:
.chainIdis legitimate on viem/EIP-1193 objects (only chain-preset accesses likeSepoliaConfig.chainIdmigrate to.id),token.approve(only matters for the Zama confidential token (approving an underlying ERC-20 before a manualwrapis unchanged), andtokenAddressappears in plenty of your own variable names — only the hook config field migrates toaddress.Verify the SDK is built once via
createConfigand<ZamaProvider>/new ZamaSDKreceive its result.Run a smoke flow (shield → transfer → unshield, or encrypt → store → decrypt) against a local
cleartext()chain first, then a testnet.Hitting a renamed error class (
TransportKeyPairExpiredError, …) or the now-nullablesdk.signer? See Handle errors.
Next steps
Help center
Stuck on the migration, or spotted a step or rename this guide is missing? Open an issue in the SDK repository — migration gaps are useful feedback. For general questions, ask the community:
Last updated