Private Transfers
Private transfers move encrypted tokens between addresses with hidden amounts.
Overview
┌─────────────┐ ┌─────────────┐
│ Alice │ │ Bob │
│ │ │ │
│ Enc(1000) │ ─── transfer ───► │ Enc(500) │
│ │ (50 tokens) │ │
│ Enc(950) │ │ Enc(550) │
└─────────────┘ └─────────────┘
On-chain visible:
✓ Sender BPK
✓ Recipient BPK
✗ Amount: Hidden (ElGamal encrypted)
Basic Transfer
await wallet.USD.transfer({
to: 'zk1qyp5xs...',
amount: 50_000000n,
});
Or using a raw BPK point:
await wallet.USD.transfer({
to: recipientBPK,
amount: 50_000000n,
});
How It Works
1. SDK Reads On-Chain State
The SDK fetches Alice's current encrypted balance from the contract and decrypts it locally using her spending key.
2. SDK Generates ZK Proof (Client-Side)
The proof attests — without revealing amounts — that:
- Alice owns the sender BPK (knows the spending key)
- Alice knows the plaintext of her current encrypted balance
- Her balance is sufficient for the transfer
- The new sender balance is correctly re-encrypted
- The transfer amount is correctly encrypted to the recipient's BPK
All proof generation happens in the browser or on the mobile device. The spending key never leaves the client.
3. Contract Verifies and Updates
On-chain, the contract:
- Verifies the ZK proof
- Checks compliance (not blacklisted/frozen)
- Replaces Alice's encrypted balance with the new (lower) ciphertext
- Adds the encrypted transfer amount homomorphically to Bob's balance
4. Recipient Decrypts
Bob reads his updated encrypted balance with a single RPC call and decrypts locally:
const balance = await bobWallet.USD.getBalance();
Gasless Transfers via Relayer
For ZK-proven operations, the proof itself is the authorization — no signature needed. The relayer simply submits the proof on-chain:
await wallet.USD.transfer({
to: 'zk1qyp5xs...',
amount: 50_000000n,
useRelayer: true,
});
See Relayer for the full authorization model.
Preparing Proofs Without Submitting
Use prepareTransfer when you need the proof but don't want to submit immediately (e.g., for atomic swaps):
const { proof, proofHex, publicInputs } = await wallet.USD.prepareTransfer(
recipientBPK,
amount,
{ authorizedCaller: swapRegistryAddress }
);
Decrypting Transfer Amounts
Both sender and recipient can decrypt transfer amounts from past transactions:
const received = await wallet.USD.decryptTransferAmount(txHash);
const sent = await wallet.USD.decryptOutgoingTransferAmount(txHash);
Transaction History
const history = await wallet.USD.getHistory({ limit: 50 });
for (const item of history) {
if (item.type === 'transfer_in') {
console.log(`Received ${item.amount} from ${item.counterparty}`);
}
if (item.type === 'transfer_out') {
console.log(`Sent ${item.amount} to ${item.counterparty}`);
}
}
See Types for the full HistoryItem shape.
Recipient Must Be Registered
The recipient must have a registered BPK before receiving transfers. Check and register:
const registered = await wallet.USD.isRegistered();
if (!registered) {
await wallet.USD.registerBPK();
}
Look up a recipient's BPK from their EVM address:
const bpk = await client.getRegisteredBpk('0x...');
if (!bpk) {
throw new Error('Recipient has not registered a BPK');
}
Gas Costs
| Operation | Gas | Notes |
|---|---|---|
transfer() | ~370k | Includes ZK proof verification + 2 EC additions |
transfer() via relayer | ~370k | Paid by relayer, not sender |
Privacy Properties
| Property | Status |
|---|---|
| Amount hidden | Yes |
| Sender BPK visible | Yes (use L3 staking for sender privacy) |
| Recipient BPK visible | Yes (use stealth addresses for recipient privacy) |
| Timing visible | Yes |
For full privacy (sender + recipient + amount), combine stealth addresses with L3 stake/unstake operations.