Skip to content

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 commitments

Token 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:

  1. Filters notes by token ID
  2. Sorts by amount (largest first)
  3. 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');
  }
}

Released under the MIT License.