Skip to main content
Version: develop

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:

  1. Reads and decrypts your current balance (fails if insufficient)
  2. Generates a ZK proof that the transfer is valid (the slowest step)
  3. Signs a controller authorization (EIP-712 signature, similar to an ERC-20 permit)
  4. 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()