Private Send

This page walks through each step of a private zERC20 transfer using the SDK.

How It Works

A private send follows three on/off-chain phases:

  1. Derive a burn address -- The sender computes a deterministic burn address from the recipient's address and a random secret using Poseidon hashing (with a 16-bit proof-of-work check).

  2. Transfer zERC20 to the burn address -- A standard ERC-20 transfer moves tokens into the burn address. The indexer records the transfer leaf.

  3. Submit an encrypted announcement -- The sender encrypts transfer metadata (secret, amount, etc.) via the ICP canister so that only the recipient can decrypt and later claim the funds.

The recipient can then scan the ICP storage canister, decrypt their announcements, and generate a ZKP to mint the equivalent zERC20 via Verifier.teleport().

Step 1: Derive Seed

Every private send starts with a seed -- a wallet-signed message that deterministically derives stealth keys.

import { getSeedMessage } from "zerc20-client-sdk";
import { keccak256, toBytes } from "viem";

// getSeedMessage() is async and returns a human-readable string for the wallet to sign
const message = await getSeedMessage();
const signature = await walletClient.signMessage({ message });

// Hash the 65-byte signature down to 32 bytes -- the SDK requires a 32-byte hex seed
const seedHex = keccak256(toBytes(signature));

getSeedMessage() returns an async Promise<string>. The wallet signature (65 bytes) must be hashed with keccak256 to produce a 32-byte hex string for seedHex. The SDK validates that seedHex is exactly 32 bytes and will throw if it is not.

Step 2: Prepare the Private Send

Call preparePrivateSend() to derive the burn address, generate the secret, and build the encrypted announcement payload.

Parameters

preparePrivateSend accepts a single PreparePrivateSendParams object:

Field
Type
Required
Description

client

StealthCanisterClient

Yes

ICP stealth client from sdk.createStealthClient()

recipientAddress

string

Yes

Recipient's EVM address

recipientChainId

number | bigint

Yes

Chain ID the recipient will claim on

seedHex

string

Yes

32-byte hex string (keccak256 of the wallet signature from Step 1)

paymentAdviceIdHex

string

No

Optional payment-advice identifier

vetkdKeyIdName

string

No

Override VetKD key ID name

Return Value

preparePrivateSend returns a PreparedPrivateSend object:

Field
Type
Description

burnAddress

string

Deterministic address to send zERC20 to

burnPayload

Uint8Array

Serialized burn data

secret

bigint

Random secret bound to the burn address

tweak

bigint

Poseidon-derived tweak value

generalRecipient

string

Generalized recipient identifier

announcement

object

Encrypted announcement ready for submission

sessionKey

Uint8Array

Ephemeral session key

paymentAdviceId

string

Resolved payment-advice identifier

paymentAdviceIdBytes

Uint8Array

Payment-advice identifier as bytes

Signature

Step 3: Transfer zERC20 to the Burn Address

Use any EVM library (viem, ethers, etc.) to execute a standard ERC-20 transfer to preparation.burnAddress.

Important: The transfer amount is not encoded in the announcement -- the indexer discovers it from the on-chain event. You can transfer any amount in a single transaction.

Step 4: Submit the Announcement

After the on-chain transfer is confirmed, submit the encrypted announcement to the ICP storage canister so the recipient can discover the transfer.

Parameters

submitPrivateSendAnnouncement accepts a SubmitPrivateSendParams object:

Field
Type
Required
Description

client

StealthCanisterClient

Yes

ICP stealth client

preparation

PreparedPrivateSend

Yes

The object returned by preparePrivateSend()

tag

string

No

Optional tag for filtering announcements

Return Value

Returns a PrivateSendResult confirming that the announcement was persisted.

Signature

Complete Example

Error Handling

Error
Cause
Resolution

SeedSignatureRejected

User rejected the wallet signature prompt

Prompt the user to sign again; the seed message is deterministic and safe to sign

BurnAddressPoWFailed

Proof-of-work check on the derived burn address did not pass

Retry preparePrivateSend() -- a new secret will be sampled

StealthClientNotConnected

createStealthClient() was not called or the ICP agent is unreachable

Verify the ICP agent host and canister IDs

AnnouncementSubmissionFailed

The ICP storage canister rejected the announcement

Check that the canister is available and the announcement payload is well-formed

InsufficientBalance

The sender does not hold enough zERC20 on the source chain

Wrap more underlying tokens via LiquidityManager.wrap() or bridge from another chain

TransactionReverted

The ERC-20 transfer call reverted on-chain

Verify token approval, balance, and that the burn address is valid

See Also

Last updated