Transfer
Private transfers move funds between ZK addresses without revealing sender, recipient, or amount.
Basic Transfer
typescript
import { parseEther } from 'viem';
await client.sync();
const txHash = await client.transfer({
recipient: 'zks1...',
amount: parseEther('100'),
});How It Works
Your Notes ZK Proof Recipient
┌────────────┐ ┌────────────┐
│ Note A │ ─┐ │ New Note │
│ 500 USDC │ │ ┌─────────────────────────────────────────┐ │ 100 USDC │
└────────────┘ ├───▶│ Proves: │ └────────────┘
┌────────────┐ │ │ 1. I own Note A and Note B │
│ Note B │ ─┘ │ 2. Total inputs = Total outputs │ ┌────────────┐
│ 200 USDC │ │ 3. Nullifiers are valid │ │ Change │
└────────────┘ │ 4. New commitments are valid │ │ 600 USDC │
└─────────────────────────────────────────┘ └────────────┘
│
▼
Contract verifies proof
Records nullifiers (spent)
Adds new commitmentsToken Transfers
Specify tokenId for token transfers:
typescript
await client.transfer({
recipient: 'zks1...',
amount: parseUnits('100', 6), // 100 USDC (6 decimals)
tokenId: BigInt(deployment.tokens.zkUSD),
});Proof Generation
Transfer requires ZK proof generation:
- Time: 30-60 seconds in browser
- Memory: ~2GB WASM heap
- CPU: Uses all available cores
Show a progress indicator:
typescript
// Start transfer (includes proof generation)
const transferPromise = client.transfer({
recipient: 'zks1...',
amount: parseEther('100'),
});
// Show loading state
setLoading(true);
try {
const txHash = await transferPromise;
console.log('Submitted:', txHash);
} finally {
setLoading(false);
}Note Selection
The SDK automatically selects which notes to spend:
- Filters notes by token ID
- Sorts by amount (largest first)
- Selects minimum notes to cover amount
For manual selection:
typescript
const notes = client.getUnspentNotes(tokenId);
const selectedNotes = notes.slice(0, 2); // Pick specific notes
await client.transfer({
recipient: 'zks1...',
amount: parseEther('100'),
inputNotes: selectedNotes,
});Consolidation
If you have many small notes, consolidate them:
typescript
const notes = client.getUnspentNotes();
// Merge 2 notes into 1
await client.consolidate({
notes: notes.slice(0, 2),
});Consolidation creates one output note with the combined value.
Gasless Transfers
With a relayer configured, transfers are gasless:
typescript
const client = new PrivacyClient({
// ...
relayerUrl: 'https://relayer.zkprivacy.dev',
});
// User doesn't need ETH for gas
await client.transfer({
recipient: 'zks1...',
amount: parseEther('100'),
});The relayer pays gas and gets reimbursed from your transfer (optional fee).
After Transfer
Always sync after the transaction confirms:
typescript
const txHash = await client.transfer({ ... });
// Wait for confirmation
await publicClient.waitForTransactionReceipt({ hash: txHash });
// Update local state
await client.sync();
// Balance now reflects the transfer
console.log('New balance:', client.getBalance());Error Handling
typescript
try {
await client.transfer({
recipient: 'zks1...',
amount: parseEther('1000'),
});
} catch (error) {
if (error.message.includes('Insufficient balance')) {
console.log('Not enough funds');
} else if (error.message.includes('Proof generation failed')) {
console.log('Proving error - check inputs');
} else if (error.message.includes('Invalid recipient')) {
console.log('Bad ZK address format');
}
}