Skip to main content

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:

  1. Verifies the ZK proof
  2. Checks compliance (not blacklisted/frozen)
  3. Replaces Alice's encrypted balance with the new (lower) ciphertext
  4. 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

OperationGasNotes
transfer()~370kIncludes ZK proof verification + 2 EC additions
transfer() via relayer~370kPaid by relayer, not sender

Privacy Properties

PropertyStatus
Amount hiddenYes
Sender BPK visibleYes (use L3 staking for sender privacy)
Recipient BPK visibleYes (use stealth addresses for recipient privacy)
Timing visibleYes

For full privacy (sender + recipient + amount), combine stealth addresses with L3 stake/unstake operations.