EVM Integration
FHE-powered private token vaults on EVM-compatible chains.
This is an early release of EVM support. It is for testing purposes only — do not use with real value. APIs may change.
Overview
Private Token Slots for EVM lets you build confidential token vaults on Ethereum, Arbitrum, Base, Optimism, and other EVM chains where:
- Balances are encrypted (no one sees how much anyone holds)
- Transfers happen privately (sender, receiver, amount all hidden)
- Only the vault owner can decrypt balances
┌─────────────────────────────────────────────────────────┐
│ Your Token Vault │
├─────────┬─────────┬─────────┬─────────┬────────────────┤
│ Slot 0 │ Slot 1 │ Slot 2 │ Slot 3 │ ... Slot 255 │
│ [ENC] │ [ENC] │ [ENC] │ [ENC] │ [ENC] │
└─────────┴─────────┴─────────┴─────────┴────────────────┘
↓ Transfer 50 from Slot 0 → Slot 1
┌─────────┬─────────┬─────────┬─────────┬────────────────┐
│ [ENC] │ [ENC] │ [ENC] │ [ENC] │ [ENC] │
└─────────┴─────────┴─ ────────┴─────────┴────────────────┘
All balances remain encrypted throughout!
How It Works
Architecture
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Your dApp │ │ EVM │ │ ZKSDK │
│ │ │ (Sepolia) │ │ Coprocessor │
│ │ │ │ │ │
│ 1. Generate │ │ │ │ │
│ FHE keys │────────────────────────→│ 2. Register │
│ │ │ │ │ keys │
│ 3. Deploy │ │ │ │ │
│ Vault │────→│ TokenVault │ │ │
│ │ │ │ │ │
│ 4. Encrypt │ │ │ │ │
│ operation │ │ │ │ │
│ (transfer │ │ 5. Submit │ │ │
│ or withdraw) │────→│ operation │────→│ 6. Compute │
│ │ │ │ │ on FHE │
│ │ │ │ │ │
│ 8. Decrypt │←────│ 7. Callback │←────│ │
│ (only you)│ │ result │ │ │
│ │ │ │ │ │
│ 9. Withdraw: │ │ Tokens sent │ │ │
│ receive │←────│ to wallet │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
What happens at each step:
- Generate FHE keys — You create keys locally. Your secret key (
clientKey) never leaves your machine. - Register keys — ServerKey and PublicKey are registered with the coprocessor.
- Deploy Vault — You deploy your ExampleTokenVault contract to an EVM chain.
- Encrypt operation — You encrypt the operation details (transfer or withdraw). No one sees the plaintext.
- Submit operation — Your vault contract sends the encrypted request to the coprocessor.
- Compute on FHE — The coprocessor does math on encrypted data. It updates balances without knowing what they are.
- Callback result — New encrypted result CID is written back to your vault contract.
- Decrypt — Only you can decrypt and see the actual balances.
- Withdraw — For withdraw operations, tokens are transferred from the vault to your wallet.
Zero-Trust Security
Your keys never leave your machine:
| Component | Where It Lives | Who Can Access |
|---|---|---|
| ClientKey | Your machine only | Only you |
| ServerKey | ZKSDK Coprocessor | Can compute, cannot decrypt |
| PublicKey | ZKSDK Coprocessor | Anyone can encrypt to vault |
The coprocessor mathematically cannot decrypt your data. It only has evaluation keys that can perform operations on ciphertext.
Quick Start
Prerequisites
- Node.js 18+
- Testnet ETH (get from Sepolia faucet)
- Private key with testnet ETH
Install Dependencies
# Clone the example repo
git clone https://github.com/zksdk-labs/example-evm-fhe-slot-vault
cd example-evm-fhe-slot-vault
# Install
npm install
# Configure environment
cp .env.example .env
# Add your PRIVATE_KEY to .env
Run the Full Demo
npm run demo
This will:
- Generate FHE keys locally
- Deploy MockToken and ExampleTokenVault contracts
- Encrypt a private transfer
- Submit on-chain
- Simulate oracle processing
- Show decrypted result
- Withdraw tokens back to your wallet
Step-by-Step Guide
1. Generate FHE Keys
Generate FHE keys locally and register them with the coprocessor:
import { SlotVaultClient } from "@zksdk/js-private-token-slots-fhe";
const client = new SlotVaultClient({
workerUrl: "https://alpha.coprocessor.zksdk.com",
});
// Generate keys locally (takes ~30 seconds)
const { vaultKeyId, clientKey, serverKey, publicKey } =
await client.createVault();
// IMPORTANT: Save your clientKey securely - it's YOUR SECRET
// The coprocessor CANNOT decrypt without it
console.log("Vault Key ID:", vaultKeyId);
console.log("ClientKey size:", (clientKey.length / 1024).toFixed(1), "KB");
# Or run via npm script
npm run 1:keys
2. Deploy ExampleTokenVault Contract
Deploy your token and vault contracts to an EVM chain:
import { ethers } from "hardhat";
const COPROCESSOR_ADDRESS = "0x57936FF1f40BeE026dE56a45921E4084207F98A0";
// Deploy MockToken first
const MockToken = await ethers.getContractFactory("MockToken");
const mockToken = await MockToken.deploy();
const tokenAddress = await mockToken.getAddress();
// Mint some tokens
await mockToken.mint(deployer.address, ethers.parseEther("10000"));
// Deploy ExampleTokenVault with all constructor args
const ExampleTokenVault = await ethers.getContractFactory("ExampleTokenVault");
const vault = await ExampleTokenVault.deploy(
COPROCESSOR_ADDRESS,
tokenAddress,
vaultKeyId
);
await vault.waitForDeployment();
console.log("Vault deployed to:", await vault.getAddress());
# Or run via npm script
npm run 2:deploy
Note: ExampleTokenVault takes the vaultKeyId in its constructor, so no separate initialization step is needed.
4. Encrypt and Submit Private Transfer
Encrypt transfer parameters and submit on-chain:
// Load your vault
await client.loadVault(vaultKeyId, clientKey);
// Encrypt transfer (sender slot, receiver slot, amount)
const senderSlot = 0;
const receiverSlot = 1;
const amount = 50;
const { encrypted_data } = client.encryptTransfer(
senderSlot,
receiverSlot,
amount
);
// Store encrypted data and get CID
const cid = await client.storeBalanceBlob(encrypted_data);
// Submit to vault contract
const dummyReceiver = "0x0000000000000000000000000000000000000001";
const tx = await vault.privateTransfer(dummyReceiver, cid);
await tx.wait();
console.log("Transfer submitted! TX:", tx.hash);
npm run 4:transfer
5. Check Result
Poll for the oracle to process the request:
const vaultKeyId = await vault.vaultKeyId();
const balanceBlobCid = await vault.balanceBlobCid();
const token = await vault.token();
console.log("Vault Key ID:", vaultKeyId);
console.log("Balance Blob CID:", balanceBlobCid);
console.log("Token:", token);
npm run 5:result
6. Decrypt Result
Only you can decrypt the result using your clientKey:
// Fetch encrypted result
const encryptedResult = await client.fetchBalanceBlob(resultCid);
// Decrypt with your clientKey
const { balances, isValid } = client.decryptResult(encryptedResult);
console.log("Transfer valid:", isValid);
console.log("Slot 0 balance:", balances[0]);
console.log("Slot 1 balance:", balances[1]);
npm run 6:decrypt
7. Withdraw Tokens
Withdraw tokens from your private vault slot back to your wallet:
// Encrypt withdraw (slot index, amount)
const slotIndex = 0;
const withdrawAmount = 25;
const { encrypted_data } = client.encryptWithdraw(slotIndex, withdrawAmount);
// Store encrypted data and get CID
const withdrawCid = await client.storeBalanceBlob(encrypted_data);
// Submit withdraw to vault contract
const amountInUnits = ethers.parseUnits(withdrawAmount.toString(), 18);
const tx = await vault.withdraw(amountInUnits, withdrawCid);
await tx.wait();
console.log("Withdraw submitted! TX:", tx.hash);
npm run 7:withdraw
Contract: ExampleTokenVault
Your vault contract inherits from PrivateTokenSlotsClient:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./PrivateTokenSlotsClient.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract ExampleTokenVault is PrivateTokenSlotsClient {
using SafeERC20 for IERC20;
IERC20 public immutable token;
string public vaultKeyId;
string public balanceBlobCid;
constructor(address coProcessor, address tokenAddress, string memory _vaultKeyId)
PrivateTokenSlotsClient(coProcessor)
{
token = IERC20(tokenAddress);
vaultKeyId = _vaultKeyId;
}
function deposit(uint256 amount, uint8 positionIndex) external returns (bytes32) {
token.safeTransferFrom(msg.sender, address(this), amount);
return _submitDeposit(vaultKeyId, positionIndex, amount);
}
function privateTransfer(address receiver, string calldata encryptedDataCid) external returns (bytes32) {
address[] memory viewers = new address[](0);
return _submitPrivateTransfer("private_token_slots_v1", encryptedDataCid, bytes32(0), viewers);
}
function withdraw(uint256 amount, string calldata encryptedDataCid) external returns (bytes32) {
return _submitWithdraw("private_token_slots_v1", encryptedDataCid, amount);
}
function _onResult(bytes32 operationId, string calldata resultCid) internal override {
balanceBlobCid = resultCid;
// Handle withdrawals: transfer tokens to user
}
}
Network Configuration
| Resource | Value |
|---|---|
| Network | Sepolia Testnet |
| CoProcessor Contract | 0x57936FF1f40BeE026dE56a45921E4084207F98A0 |
| FHE Worker URL | https://alpha.coprocessor.zksdk.com |
Supported Networks
| Network | RPC URL | Status |
|---|---|---|
| Sepolia | https://eth-sepolia.g.alchemy.com/v2/ | Active |
| Arbitrum Sepolia | https://sepolia-rollup.arbitrum.io/rpc | Coming Soon |
| Base Sepolia | https://sepolia.base.org | Coming Soon |
| Optimism Sepolia | https://sepolia.optimism.io | Coming Soon |
Storage
Encrypted data is stored off-chain. Configure storage in your SDK setup:
const client = new SlotVaultClient({
workerUrl: "https://alpha.coprocessor.zksdk.com",
storageType: "postgres", // Current default
});
| Type | Status |
|---|---|
postgres | Available |
celestia, avail, eigenda, ipfs | Coming Soon |
See full Storage documentation for all options and configuration details.
Files Created
After running the demo:
keys/
├── client.key # YOUR SECRET - backup this!
├── vault-key-id.txt # Vault identifier (64-char hex)
├── vault-address.txt # Deployed contract address
└── last-request-cid.txt # Last submitted request
Packages
| Package | Platform | Version |
|---|---|---|
| @zksdk/core | npm | 0.1.7 |
| @zksdk/js-private-token-slots-fhe | npm | 0.1.8 |
| @zksdk/evm-core | npm | 0.1.1 |
Limitations (Alpha)
- Slot balances: 0-255 per slot (8-bit)
- Slots per vault: Up to 256
- Not audited: Do not use with real value
Troubleshooting
"insufficient funds"
Get testnet ETH:
- Sepolia: https://sepoliafaucet.com
- Base: https://www.coinbase.com/faucets/base-ethereum-goerli-faucet
"COPROCESSOR_ADDRESS not set"
Copy .env.example to .env:
cp .env.example .env
Contract verification
Verify on Etherscan:
npx hardhat verify --network sepolia <VAULT_ADDRESS> <COPROCESSOR_ADDRESS>
Support
- GitHub: example-evm-fhe-slot-vault
- Issues: Report bugs
- Contact: Get in touch