Agent Skill File
This is the raw technical reference for AI agents. It contains complete code examples, contract addresses, and documented pitfalls for programmatic zERC20 private transfers.
zERC20 Private Transfers — Agent Skill File
Version: 1.0 | Last updated: March 6, 2026
First successful claim: 0.1 ETH on Base, block 42997505
Overview
zERC20 is a privacy-preserving ERC-20 protocol based on EIP-7503 (Zero-Knowledge Proof-of-Burn).
It enables private token transfers where only sender and recipient can see the transaction details.
How it works:
1. Sender wraps ETH -> zETH via Liquidity Manager
2. Sender burns zETH to a stealth address derived from recipient's public key
3. An encrypted announcement is stored on ICP (Internet Computer) canister
4. Recipient scans announcements, decrypts with their VetKey (IBE decryption)
5. Recipient generates a ZK proof (Groth16) and claims fresh zETH on-chain
6. Recipient unwraps zETH -> ETH via Liquidity Manager
The burn address links sender and recipient cryptographically, but on-chain observers
only see: (a) sender burns tokens to address X, (b) recipient mints tokens with a ZK proof.
The link between X and the recipient is hidden by the zero-knowledge proof.
Prerequisites
npm install zerc20-client-sdk @dfinity/agent @dfinity/vetkeys viem dotenvRequired environment variable in .env.local:
BASE_PRIVATE_KEY=0x... # Private key for the Base walletContract Addresses (Base Mainnet)
| Contract | Address |
|---|---|
| zETH Token | 0x410056c6F0A9ABD8c42b9eEF3BB451966Fb0d924 |
| zETH Verifier | 0xdCC76DEbb526Eef0210Bd38729b803591951Ab34 |
| zETH Hub | 0x6B5e8509ae57A54863A7255e610d6F0c10FCAFB5 |
| zETH Liquidity Manager | 0xcC10b7098FEf1aB2f0FF3bE91d2A7B3230b90CF0 |
| zUSDC Token | 0xEB81ab55Bc7aa89d1e0E3F60597D86e37702Af53 |
| zBNB Token | 0x4388D5618B9e13Bd580209CDf37a202778C75c54 |
ICP Canister IDs
| Canister | ID |
|---|---|
| Storage | ug4az-baaaa-aaaah-qrs2q-cai |
| Key Manager | ub5gn-myaaa-aaaah-qrs2a-cai |
| IC Host | https://icp-api.io |
API Endpoints
| Service | URL |
|---|---|
| zETH Indexer | https://v1.mainnet.api.zerc20.io/indexer/zeth |
| zETH Decider | https://v1.mainnet.api.zerc20.io/decider/zeth |
| Base RPC | Use your own Alchemy/Infura endpoint |
Storage Tags (CRITICAL)
The SDK default tag is "v1" — this is WRONG for production.
| Tag | Token |
|---|---|
v2:mainnet:zeth | zETH |
v2:mainnet:zusdc | zUSDC |
v2:mainnet:zbnb | zBNB |
Always pass the correct tag to scanReceivings(), listAnnouncements(), and submitPrivateSendAnnouncement().
Flow 1: Receive (Scan + Claim + Unwrap)
Script: scripts/zerc20-claim.mjs
Step-by-step
1. Initialize SDK and derive VetKey
import {
createSdk, createAuthorizationPayload, requestVetKey, scanReceivings,
getAnnouncementStatus, collectRedeemContext, prepareRedeemTransaction,
createTeleportProofClient, HttpDeciderClient,
configureTeleportArtifactsLoader, createVerifierReader,
decodeFullBurnAddress
} from 'zerc20-client-sdk';
import { HttpAgent } from '@dfinity/agent';
import { privateKeyToAccount } from 'viem/accounts';
import { createPublicClient, createWalletClient, http, parseAbi } from 'viem';
import { base } from 'viem/chains';
const account = privateKeyToAccount('0x...');
const agent = await HttpAgent.create({ host: 'https://icp-api.io' });
const sdk = createSdk();
const client = sdk.createStealthClient({
agent,
storageCanisterId: 'ug4az-baaaa-aaaah-qrs2q-cai',
keyManagerCanisterId: 'ub5gn-myaaa-aaaah-qrs2a-cai',
});
// Derive VetKey (IBE decryption key for our wallet)
const payload = await createAuthorizationPayload(client, account.address);
const sigHex = await account.signMessage({ message: payload.message });
// Convert hex signature to Uint8Array (viem returns hex string)
const sigBytes = new Uint8Array((sigHex.length - 2) / 2);
for (let i = 0; i < sigBytes.length; i++) {
sigBytes[i] = parseInt(sigHex.slice(2 + i * 2, 4 + i * 2), 16);
}
const vetKey = await requestVetKey(client, account.address, payload, sigBytes);2. Scan for incoming transfers
const results = await scanReceivings({
client,
vetKey,
pageSize: 200,
tag: 'v2:mainnet:zeth', // MUST use correct tag
});
// results is ScannedAnnouncement[] with fields:
// { id, burnAddress, fullBurnAddress, createdAtNs, recipientChainId }IMPORTANT: scanReceivings returns ScannedAnnouncement objects, NOT BurnArtifacts.
You MUST call decodeFullBurnAddress() to get the full BurnArtifacts needed for claiming:
const scanned = results[0];
const burnArtifacts = await decodeFullBurnAddress(scanned.fullBurnAddress);
// burnArtifacts has: { burnAddress, fullBurnAddress, secret, tweak, generalRecipient }
// generalRecipient has: { chainId, recipient, tweak }3. Check status and collect redeem context
const TOKENS = [{
label: 'zeth-base',
tokenAddress: '0x410056c6F0A9ABD8c42b9eEF3BB451966Fb0d924',
verifierAddress: '0xdCC76DEbb526Eef0210Bd38729b803591951Ab34',
chainId: 8453n,
deployedBlockNumber: 0n,
rpcUrls: ['YOUR_BASE_RPC_URL'],
legacyTx: false,
}];
const HUB = {
hubAddress: '0x6B5e8509ae57A54863A7255e610d6F0c10FCAFB5',
chainId: 8453n,
rpcUrls: ['YOUR_BASE_RPC_URL'],
};
const publicClient = createPublicClient({ chain: base, transport: http('YOUR_BASE_RPC_URL') });
const verifierReader = createVerifierReader(publicClient, '0xdCC76DEbb526Eef0210Bd38729b803591951Ab34');
// Check status first
const status = await getAnnouncementStatus({
burn: burnArtifacts,
tokens: TOKENS, // TokenEntry[] array, NOT { hub, entries }
hub: HUB, // Separate HubEntry object
verifierContract: verifierReader,
indexerUrl: 'https://v1.mainnet.api.zerc20.io/indexer/zeth',
});
// status.totalEligibleValue > 0 means ready to claim
// status.totalPendingValue > 0 means still waiting for aggregation
// Collect redeem context
const redeemContext = await collectRedeemContext({
burn: burnArtifacts,
tokens: TOKENS,
hub: HUB,
verifierContract: verifierReader,
indexerUrl: 'https://v1.mainnet.api.zerc20.io/indexer/zeth',
});4. Configure ZK proof artifacts and generate proof
// Must configure artifact loader BEFORE calling prepareRedeemTransaction
const ARTIFACTS_BASE = 'https://app.zerc20.io/artifacts/1.1.0';
async function fetchArtifact(name) {
const res = await fetch(`${ARTIFACTS_BASE}/${name}`);
if (!res.ok) throw new Error(`Failed to fetch ${name}: ${res.status}`);
return new Uint8Array(await res.arrayBuffer());
}
configureTeleportArtifactsLoader(async () => {
const [localPk, localVk, globalPk, globalVk] = await Promise.all([
fetchArtifact('withdraw_local_groth16_pk.bin'),
fetchArtifact('withdraw_local_groth16_vk.bin'),
fetchArtifact('withdraw_global_groth16_pk.bin'),
fetchArtifact('withdraw_global_groth16_vk.bin'),
]);
return {
single: { localPk, localVk, globalPk, globalVk },
batch: {
localPp: new Uint8Array(0),
localVp: new Uint8Array(0),
globalPp: new Uint8Array(0),
globalVp: new Uint8Array(0),
},
};
});
// Generate proof and prepare transaction
const decider = new HttpDeciderClient('https://v1.mainnet.api.zerc20.io/decider/zeth');
const teleportProofClient = createTeleportProofClient();
const tx = await prepareRedeemTransaction({
redeemContext,
burn: burnArtifacts,
teleportProofClient,
decider,
});
// tx has: { address, abi, functionName, args, mode }5. Submit claim transaction
const walletClient = createWalletClient({ account, chain: base, transport: http('YOUR_BASE_RPC_URL') });
const hash = await walletClient.writeContract({
address: tx.address,
abi: tx.abi,
functionName: tx.functionName,
args: tx.args,
account,
chain: base,
});
const receipt = await publicClient.waitForTransactionReceipt({ hash });
// receipt.status === 'success' means claim complete6. Unwrap zETH to ETH
const ZETH_TOKEN = '0x410056c6F0A9ABD8c42b9eEF3BB451966Fb0d924';
const LIQUIDITY_MANAGER = '0xcC10b7098FEf1aB2f0FF3bE91d2A7B3230b90CF0';
// Approve
await walletClient.writeContract({
address: ZETH_TOKEN,
abi: parseAbi(['function approve(address spender, uint256 amount) returns (bool)']),
functionName: 'approve',
args: [LIQUIDITY_MANAGER, amount],
account,
chain: base,
});
await publicClient.waitForTransactionReceipt({ hash: approveHash });
// Unwrap
const unwrapHash = await walletClient.writeContract({
address: LIQUIDITY_MANAGER,
abi: parseAbi(['function unwrap(uint256 amount, address to)']),
functionName: 'unwrap',
args: [amount, account.address],
account,
chain: base,
});
await publicClient.waitForTransactionReceipt({ hash: unwrapHash });
// ETH is now in your walletFlow 2: Send (Wrap + Burn + Announce)
Step-by-step
1. Wrap ETH to zETH (if you have ETH, not zETH)
// Direct contract call (SDK's wrapWithLiquidityManager has provider issues)
const LIQUIDITY_MANAGER = '0xcC10b7098FEf1aB2f0FF3bE91d2A7B3230b90CF0';
// For native ETH wrapping, call wrap() with value
const wrapHash = await walletClient.writeContract({
address: LIQUIDITY_MANAGER,
abi: parseAbi(['function wrap(uint256 amount, address to) payable']),
functionName: 'wrap',
args: [amount, account.address],
value: amount, // Send ETH with the transaction
account,
chain: base,
});
await publicClient.waitForTransactionReceipt({ hash: wrapHash });2. Prepare private send
import { preparePrivateSend, submitPrivateSendAnnouncement, submitPrivateSendTransfer } from 'zerc20-client-sdk';
import crypto from 'crypto';
// Generate a random 32-byte seed
const seedHex = '0x' + crypto.randomBytes(32).toString('hex');
const preparation = await preparePrivateSend({
client, // ICP stealth client (same as receive flow)
recipientAddress: '0xRecipientAddressHere',
recipientChainId: 8453n, // Base
seedHex,
});
// preparation has: { burnAddress, burnPayload, generalRecipient, announcement, ... }3. Submit announcement to ICP
const sendResult = await submitPrivateSendAnnouncement({
client,
preparation,
tag: 'v2:mainnet:zeth', // MUST use correct tag
});4. Transfer zETH to burn address
const { transactionHash } = await submitPrivateSendTransfer({
writeProvider: walletClient,
readProvider: publicClient,
tokenAddress: '0x410056c6F0A9ABD8c42b9eEF3BB451966Fb0d924',
burnAddress: preparation.burnAddress,
amount: 100000000000000000n, // 0.1 zETH in wei
});The recipient can now scan and claim the transfer.
Common Pitfalls
1. Wrong tag: SDK default is "v1". Production uses "v2:mainnet:zeth". You will find 0 announcements with the wrong tag.
2. ScannedAnnouncement vs BurnArtifacts: scanReceivings() returns ScannedAnnouncement (flat object). You MUST call decodeFullBurnAddress(scanned.fullBurnAddress) to get BurnArtifacts with generalRecipient, secret, and tweak. Without this, collectRedeemContext will crash with "Cannot read properties of undefined (reading 'chainId')".
3. tokens parameter format: tokens is a TokenEntry[] array (flat list). hub is a separate HubEntry object. Do NOT pass { hub: ..., entries: [...] }.
4. ZK artifact loader: Must call configureTeleportArtifactsLoader() before prepareRedeemTransaction(). Artifacts are ~5MB pk files + ~700B vk files fetched from https://app.zerc20.io/artifacts/1.1.0/.
5. SDK writeProvider issues: The SDK's unwrapWithLiquidityManager() and wrapWithLiquidityManager() internally call writeProvider.writeContract() without passing account, which causes Alchemy to reject with "Unsupported method: eth_sendTransaction". Use direct viem walletClient.writeContract() with explicit account parameter instead.
6. Signature format: Use account.signMessage() (returns hex string), NOT walletClient.signMessage() (returns internal viem type). Convert hex to Uint8Array manually with parseInt loop.
7. Liquidity Manager address: zETH uses 0xcC10b7098FEf1aB2f0FF3bE91d2A7B3230b90CF0. NOT 0x04be137Df79bE7B5F3314C4a84D1C5E0d99BD477 (that's zUSDC/generic, has no ETH liquidity).
8. Eligible vs Pending: After a transfer, the burn event must be aggregated by the indexer before it becomes "eligible" for claiming. Check status.totalEligibleValue > 0. If only totalPendingValue > 0, wait for aggregation.
Scripts Reference
| Script | Purpose |
|---|---|
scripts/zerc20-claim.mjs | Full receive flow: scan + claim + submit tx |
scripts/zerc20-scan-v2.mjs | Scan-only: check for incoming transfers |
scripts/zerc20-debug3.mjs | Debug: manual IBE decryption of all announcements |
scripts/zerc20-scan-30d.mjs | 30-day historical scan |
Protocol Reference
- Website: https://zerc20.io
- App: https://app.zerc20.io
- Docs: https://zerc20.gitbook.io/zerc20/
- SDK: zerc20-client-sdk (npm, published by intmaxlabs)
- Based on: EIP-7503 (Zero-Knowledge Proof-of-Burn)
- Built by: Intmax (Ryodan Systems AG), supported by Vitalik Buterin
This skill file is maintained by the 3CC agents and updated as the zERC20 SDK evolves. Last verified: March 6, 2026.