Action Extensions
Viem action extensions that add ZKP capabilities to clients.
zkpPublicActions
On-chain read operations. Works with any PublicClient.
import { zkpPublicActions } from '@cardinal-cryptography/core'
client.extend(zkpPublicActions({ tokens?: TokenMap }))
| Method | Parameters | Returns | Description |
|---|---|---|---|
getEncryptedBalanceCiphertext | { token, zkpAddress } | ElGamalCiphertext | Read encrypted balance |
getAutoEncryptZkpAddress | { token, address } | ZkpAddress | null | Reverse lookup: EVM address to ZK address |
getQuorumPublicKey | { token } | GrumpkinPublicKey | Quorum encryption public key |
getRegistration | { token, zkpAddress | epk } | EvmAddress | null | Controller registered for an EPK on the token's ZKHub, or null if unregistered |
isRegistered | { token, zkpAddress | epk } | boolean | Whether an EPK is registered (non-zero controller) |
getPermitNonce | { token, owner } | bigint | Next permit nonce for controller auth |
getAutoEncrypt | { token, address } | boolean | Is auto-encrypt enabled? |
getEncryptedBalanceLogs | { token, zkpAddress, fromBlock, toBlock?, events? } | EncryptedBalanceLog[] | Raw event history. fromBlock is required; caller paginates with successive [fromBlock, toBlock] windows to stay under hosted-RPC block-range caps. |
getTokenName | { token } | string | Token contract name (for EIP-712 domain) |
getPublicTokenBalance | { token, owner } | bigint | ERC-20 balanceOf on the token contract (public L1 balance) |
getPublicTokenBalances | { owner, tokenKeys? } | Record<Token, bigint> | Batch balanceOf for default or configured tokens. Requires client.chain when using baked-in token maps. |
Amount helpers (formatAmount, parseAmount, safeParseAmount, TOKEN_DECIMALS) live in @cardinal-cryptography/core — 6 decimal places; parseAmount rejects negatives and rounds excess fractional digits (viem parseUnits behavior). safeParseAmount(value) is the non-throwing variant for per-keystroke input validation: it returns { value: bigint } on success or { error: string } (the message parseAmount would have thrown) instead of raising.
Error handling
A failed send* throws a decoded error — SendRevertError for a mined revert (carries .transactionHash and a best-effort .decoded) or UserOpRevertError for a bundler pre-submission reject (see Transaction Lifecycle). So for a caught send* failure you read err.decoded, not a separate decoder. A user declining in their wallet is not a failure: it surfaces as viem's standard UserRejectedRequestError (EIP-1193 4001), which viem nests under a higher-level error — so detect it with viem's own error.walk cause-chain helper, not by inspecting the top-level error.
import { BaseError, UserRejectedRequestError } from 'viem'
import { SendRevertError } from '@cardinal-cryptography/core'
try {
await client.sendEncryptedTransfer({ token, amount, to })
} catch (err) {
// User declined in their wallet — not an error.
if (err instanceof BaseError && err.walk(e => e instanceof UserRejectedRequestError)) return
// Reverted on-chain — read the best-effort decoded reason.
if (err instanceof SendRevertError) {
console.error(err.decoded?.summary ?? err.message)
return
}
throw err
}
To decode raw revert bytes you hold from elsewhere (a UserOperationRevertReason event, a manual eth_call), pass them to decodeUserOpError(bytesOrError) — it resolves them against all ZK contract errors (EntryPoint + SharedAccount + ZKEMT + ZKHub), see Gas sponsorship — decoding reverts.
Consumer-relevant error names: EpkAlreadyRegistered (hub) and AutoEncryptEpkAlreadySet (token) are distinct errors (controller already registered vs. auto-encrypt EPK already set); also InvalidProof, NonceAlreadyUsed, SameSenderRecipient, InvalidAuthorizationSignature.
zkpEvmActions
EVM write transactions. Requires WalletClient or BundlerLike.
import { zkpEvmActions } from '@cardinal-cryptography/core'
client.extend(zkpEvmActions({ tokens?: TokenMap, account?: LocalAccount }))
account is required on the bundler path (and ignored on the WalletClient
path, where viem's client.account plays the same role). It's used to sign
EIP-712 permits for publicToEncryptedTransferWithAuth.
| Method | Parameters | Returns | Description |
|---|---|---|---|
preparePublicToEncryptedTransfer | { token, amount, zkpAddress } | PreparedTransaction | Prepare public-to-encrypted transfer (plain variant, WalletClient path) |
preparePublicToEncryptedTransferWithAuth | { token, amount, zkpAddress, owner, deadline, nonce? } | UnsignedTransaction | Prepare permit variant — returned shape needs signing before submission |
prepareSetAutoEncrypt | { token, enabled, zkpAddress } | PreparedTransaction | Prepare auto-encrypt initial bind (sets the EPK and the enabled flag). Reverts on-chain if the address has already bound an EPK. |
prepareToggleAutoEncrypt | { token, enabled } | PreparedTransaction | Prepare auto-encrypt on/off flip for an already-bound address. Does not change the bound EPK. |
sendPublicToEncryptedTransfer | { token, amount, zkpAddress, account?, nonce?, deadline? } | 0x${string} | Auto-routes: plain variant on WalletClient, WithAuth on bundler (prepare → sign → send under the hood) |
sendSetAutoEncrypt | { token, enabled, zkpAddress, account?, nonce?, deadline? } | 0x${string} | Auto-routes: plain variant on WalletClient, WithAuth on bundler. Initial bind only — reverts on-chain if the address has already bound an EPK. account?, nonce?, deadline? apply to the bundler path only. |
sendToggleAutoEncrypt | { token, enabled, account?, nonce?, deadline? } | 0x${string} | Auto-routes: plain variant on WalletClient, WithAuth on bundler. Flips the enabled flag for an already-bound address; on the bundler path the SDK reads the stored EPK from chain to construct the digest. |
signAuthorization | UnsignedTransaction | PreparedTransaction | Sign an EIP-712 permit with the bound account. Named distinctly from zkpWalletActions.signTransaction so both can coexist on a full client. |
toSignedTransaction | (UnsignedTransaction, HexSignature) | PreparedTransaction | Assemble a PreparedTransaction from an externally-produced signature (custody, HSM, etc.) |
Permit prepare/sign/submit flow
publicToEncryptedTransferWithAuth takes an EIP-712 permit as its last argument. Use preparePublicToEncryptedTransferWithAuth (returns UnsignedTransaction) + signAuthorization + sendPreparedTransaction. For external signers (custody, Ledger, Fireblocks), use toSignedTransaction to wrap an externally-produced signature.
See Transaction Lifecycle — EVM transactions with an EIP-712 permit for the three-step code example.
erc3009Actions
EIP-3009 transferWithAuthorization — plain ERC-20 transfers via a recipient-bound off-chain authorization. The signed digest commits to to, so anyone (the SharedAccount bundler in practice) may forward the call without being able to redirect funds. Bundler-only — on a WalletClient call transfer directly.
import { erc3009Actions } from '@cardinal-cryptography/core'
client.extend(erc3009Actions({ tokens?: TokenMap, account?: LocalAccount }))
account is the EOA whose tokens are transferred — also the EIP-712 signer (from = account.address). Required for sendTransferWithAuthorization; prepare* and reads don't need it.
| Method | Parameters | Returns | Description |
|---|---|---|---|
prepareTransferWithAuthorization | { token, from, to, value, validAfter?, validBefore?, nonce? } | UnsignedTransaction | Build EIP-712 typed data + calldata. Caller signs typedData and assembles a PreparedTransaction via signTransaction / toSignedTransaction. |
sendTransferWithAuthorization | { token, to, value, validAfter?, validBefore?, nonce? } | 0x${string} | prepare → sign with the bound account → submit via the bundler. |
getAuthorizationState | { token, authorizer, nonce } | boolean | Has (authorizer, nonce) been consumed (used or canceled)? Mirrors EIP-3009's authorizationState view. |
Defaults: validAfter = 0n, validBefore = 20 min ahead, nonce is a fresh random bytes32.
zkpDecryptActions
Decrypt balances and history. Requires a client with zkpPublicActions methods.
import { zkpDecryptActions } from '@cardinal-cryptography/core'
client.extend(zkpDecryptActions({ zkpAccount: ZkpReadAccount }))
| Method | Parameters | Returns | Description |
|---|---|---|---|
getDecryptedBalance | { token, zkpAccount? } | bigint | Read + decrypt balance |
getDecryptedBalanceLogs | { token, fromBlock, toBlock?, events?, zkpAccount? } | DecryptedBalanceLog[] | Decrypt history. fromBlock is required (forwarded to getEncryptedBalanceLogs). |
The optional zkpAccount parameter overrides the account from config (useful for multi-account views).
zkpWalletActions
Prepare, sign, and send encrypted transfers. Requires a client with zkpPublicActions + zkpPreparedActions methods.
import { zkpWalletActions } from '@cardinal-cryptography/core'
client.extend(zkpWalletActions({ zkpAccount: ZkpAccount }))
| Method | Parameters | Returns | Description |
|---|---|---|---|
prepareEncryptedTransfer | { token, amount, to, deadline?, nonce?, onProgress?, zkpAccount? } | UnsignedTransaction | Prepare private transfer |
prepareEncryptedToPublicTransfer | { token, amount, recipient, deadline?, nonce?, onProgress?, zkpAccount? } | UnsignedTransaction | Prepare encrypted-to-public transfer |
sendEncryptedTransfer | Same as prepare | 0x${string} | Full flow: prepare + sign + send |
sendEncryptedToPublicTransfer | Same as prepare | 0x${string} | Full flow: prepare + sign + send |
prepareRegisterEpk | { token, controller, onProgress?, zkpAccount? } | PreparedTransaction | Bind a controller EVM address to this account's EPK on the token contract. Required first transaction for every new ZKP account. |
sendRegisterEpk | Same as prepare | 0x${string} | Full flow: prove + submit |
signTransaction | UnsignedTransaction | PreparedTransaction | Sign with the controller key (ZKP actions). Permit signing lives under zkpEvmActions.signAuthorization. |
getDecryptedBalance | { token, zkpAccount? } | bigint | Inherited from decrypt actions |
getDecryptedBalanceLogs | { token, ... } | DecryptedBalanceLog[] | Inherited from decrypt actions |
zkpPreparedActions
Submit pre-signed transactions. Handles routing between WalletClient and BundlerClient.
import { zkpPreparedActions } from '@cardinal-cryptography/core'
client.extend(zkpPreparedActions())
| Method | Parameters | Returns | Description |
|---|---|---|---|
sendPreparedTransaction | PreparedTransaction, options?: { waitForReceipt?: boolean } | SendReceipt | PendingSend | Submit a signed transaction. Waits for inclusion and resolves { transactionHash } by default (a reverted tx throws SendRevertError); { waitForReceipt: false } resolves immediately with { hash, waitForReceipt() } (a userOp hash on the bundler path) — confirm later via the returned waitForReceipt(). |
waitForReceipt(client, hash) (exported from @cardinal-cryptography/core) waits until a submitted transaction is mined and resolves a { transactionHash } receipt — dispatching to waitForUserOperationReceipt (bundler) or waitForTransactionReceipt (WalletClient). The convenience send* methods take the same { waitForReceipt?: boolean } option and, when false, return a { hash, waitForReceipt() } pending send. A reverted transaction throws SendRevertError (carrying transactionHash, path, and a best-effort decoded reason) on both the default wait and the deferred waitForReceipt().