Encrypted Transfers
Send private transfers between ZK addresses. The chain verifies the transfer is valid via a zero-knowledge proof, without seeing the amount or balances involved.
This requires a full account -- one that can decrypt balances, generate ZK proofs, and sign controller authorizations.
Setup
import { createFullClient, createRuntime } from '@cardinal-cryptography/sdk'
import { createPublicClient, http } from 'viem'
import { baseSepolia } from 'viem/chains'
// Full runtime: decryption + ZK proving
const runtime = await createRuntime()
// Full account: decrypt + prove + sign
const account = runtime.createAccountFromMnemonic('your mnemonic here')
// Gas-sponsored by default via bundler + paymaster
const client = await createFullClient({
client: createPublicClient({ chain: baseSepolia, transport: http() }),
zkpAccount: account,
})
Send an encrypted transfer
const txHash = await client.sendEncryptedTransfer({
token: 'zkUSD',
amount: 100n,
to: 'zk1recipientAddress...',
})
Under the hood, this:
- Reads and decrypts your current balance (fails if insufficient)
- Generates a ZK proof that the transfer is valid (the slowest step)
- Signs a controller authorization (EIP-712 signature, similar to an ERC-20 permit)
- Submits the transaction
Encrypted-to-public transfer
Move tokens from your encrypted balance back to a public EVM address:
const txHash = await client.sendEncryptedToPublicTransfer({
token: 'zkUSD',
amount: 50n,
recipient: '0xEvmRecipient...',
})
Step-by-step flow
For custom UIs or more control, split the operation into prepare/sign/send:
// Step 1: Prepare -- reads balance, decrypts, generates ZK proof
// Returns an UnsignedTransaction with the proof and EIP-712 typed data
const unsigned = await client.prepareEncryptedTransfer({
token: 'zkUSD',
amount: 100n,
to: 'zk1recipientAddress...',
})
// Step 2: Sign -- signs the EIP-712 authorization with your controller key
// This is NOT a wallet signature -- it's a contract-level authorization
const signed = await client.signTransaction(unsigned)
// Step 3: Send -- submits the signed transaction
const txHash = await client.sendPreparedTransaction(signed)
Progress callbacks
Proof generation takes time. Use onProgress to show feedback in your UI:
await client.sendEncryptedTransfer({
token: 'zkUSD',
amount: 100n,
to: 'zk1recipientAddress...',
onProgress: ({ step, total, stage }) => {
console.log(`[${step}/${total}] ${stage}`)
// Output:
// [1/6] reading-balance
// [2/6] decrypting
// [3/6] generating-proof <- slowest step
// [4/6] building-transaction
// [5/6] signing
// [6/6] submitting
},
})
Deadline
Each transfer includes a deadline -- a Unix timestamp (seconds) after which the contract rejects the transaction. This prevents stale proofs from being replayed.
The SDK defaults deadline to 20 minutes ahead of Date.now(). To override:
await client.sendEncryptedTransfer({
token: 'zkUSD',
amount: 100n,
to: 'zk1recipientAddress...',
deadline: BigInt(Math.floor(Date.now() / 1000) + 600), // 10 minutes
})
Cleanup
runtime.destroy()