Examples
Self-contained, runnable examples for @tokenops/sdk/fhe-airdrop. The hardcoded private keys are the well-known Anvil/Hardhat defaults — public, do not use with real funds.
Create and fund an airdrop
Admin: create a confidential airdrop and fund it in a single transaction.
Prerequisites
- The Sepolia factory is pre-deployed and resolves automatically; the mainnet slot is still pending. See
src/core/addresses.ts. - Call
token.setOperator(factoryAddress, deadline)so the factory can transfer tokens on your behalf.
import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { RelayerNode, SepoliaConfig } from "@zama-fhe/sdk/node";
import {
createConfidentialAirdropFactoryClient,
type AirdropParams,
} from "@tokenops/sdk/fhe-airdrop";
async function main() {
const account = privateKeyToAccount(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
);
const rpcUrl = "https://rpc.sepolia.org";
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });
// SepoliaConfig provides the KMS verifier, ACL, input verifier, and public relayer URL.
const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});
// The Sepolia factory address resolves automatically from publicClient.chain.id.
// Pass `address` explicitly only for self-deployed factories (forks, custom testnets).
const factory = createConfidentialAirdropFactoryClient({
publicClient,
walletClient,
encryptor,
});
const token = process.env.ERC7984_TOKEN_ADDRESS as `0x${string}`;
const now = Math.floor(Date.now() / 1000);
const params: AirdropParams = {
token,
startTimestamp: now + 60, // claims open in 1 minute
endTimestamp: now + 30 * 86400, // 30-day claim window
canExtendClaimWindow: true, // admin can extend later
admin: account.address,
};
// Any 32-byte value unique to this campaign. Different salts produce different clone addresses.
const userSalt = "0x0000000000000000000000000000000000000000000000000000000000000001" as const;
// Deploy and fund in a single tx. amount is raw token units (ERC-7984 uses 6 decimals).
// 10_000_000n = 10 tokens at 6 decimals.
const { hash, airdrop } = await factory.createAndFundConfidentialAirdrop({
params,
userSalt,
amount: 10_000_000n,
});
console.log("createAndFundConfidentialAirdrop tx:", hash);
console.log("Deployed airdrop:", airdrop);
}
export { main };
Claim with an admin-issued authorization
User: claim confidential tokens using an admin-issued EIP-712 authorization. The admin calls signClaimAuthorization() off-chain (e.g. in a server action) and returns the encrypted handle + signature to the user. The user submits them here with msg.value == GAS_FEE().
import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { RelayerNode, SepoliaConfig } from "@zama-fhe/sdk/node";
import {
createConfidentialAirdropClient,
encryptUint64,
signClaimAuthorization,
} from "@tokenops/sdk/fhe-airdrop";
// ── Admin side (server action / API route) ────────────────────────────────────
// In production, the admin runs this server-side and returns the signature.
async function buildClaimPayload(
adminWalletClient: ReturnType<typeof createWalletClient>,
airdropAddress: `0x${string}`,
recipient: `0x${string}`,
amount: bigint,
encryptor: InstanceType<typeof RelayerNode>,
) {
// 1. Encrypt BOUND TO THE RECIPIENT — Zama input proofs are bound to
// (contractAddress, userAddress) at encrypt time. FHE.fromExternal
// rejects any other binding.
const encrypted = await encryptUint64({
encryptor,
contractAddress: airdropAddress,
userAddress: recipient,
value: amount,
});
// 2. Sign the EIP-712 Claim struct. The signature commits to the exact
// bytes32 handle from step 1 — do NOT re-encrypt before submitting.
const signature = await signClaimAuthorization({
walletClient: adminWalletClient,
airdropAddress,
recipient,
encryptedAmountHandle: encrypted.handle,
});
return { encrypted, signature };
}
// ── User side (frontend / wallet) ─────────────────────────────────────────────
async function main() {
const userAccount = privateKeyToAccount(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
);
const adminAccount = privateKeyToAccount(
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
);
const rpcUrl = "https://rpc.sepolia.org";
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const userWalletClient = createWalletClient({
account: userAccount,
chain: sepolia,
transport: http(rpcUrl),
});
const adminWalletClient = createWalletClient({
account: adminAccount,
chain: sepolia,
transport: http(rpcUrl),
});
const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});
const airdropAddress = process.env.AIRDROP_ADDRESS as `0x${string}`;
const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient: userWalletClient,
address: airdropAddress,
});
const isOpen = await airdrop.isClaimWindowActive();
if (!isOpen) throw new Error("Claim window is not active");
// Admin builds and signs the payload (server-side in production).
// 500_000n = 0.5 tokens at 6 decimals.
const { encrypted, signature } = await buildClaimPayload(
adminWalletClient,
airdropAddress,
userAccount.address,
500_000n,
encryptor,
);
// Pre-validate the signature before paying gas. The on-chain check binds
// to msg.sender — pass the address that will actually submit the claim.
const valid = await airdrop.isSignatureValid({
encryptedAmountHandle: encrypted.handle,
signature,
caller: userAccount.address,
});
if (!valid) throw new Error("Signature is invalid or already claimed");
// GAS_FEE() is automatically fetched and attached as msg.value.
// ClaimArgs is a discriminated union: provide EITHER `encryptedInput`
// (the admin's pre-built handle) OR plaintext `amount`. The signature is
// bound to the admin's specific handle, so reusing encryptedInput is required.
const hash = await airdrop.claim({
signature,
encryptedInput: encrypted,
});
console.log("Claim tx:", hash);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
console.log("Status:", receipt.status); // "success"
}
export { main };
Reveal an encrypted balance handle
User: peek at your airdrop allocation as an encrypted handle, then decrypt it. getClaimAmount() is a write transaction (not a free view) — it calls FHE.allow(handle, msg.sender) on-chain so the Zama relayer can authorize your userDecrypt request. The handle is extracted from the receipt's ACL Allowed event — simulation would return a divergent handle.
import { createPublicClient, createWalletClient, http } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
import { RelayerNode, SepoliaConfig } from "@zama-fhe/sdk/node";
import {
createConfidentialAirdropClient,
encryptUint64,
signClaimAuthorization,
} from "@tokenops/sdk/fhe-airdrop";
async function main() {
const userAccount = privateKeyToAccount(
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
);
const adminAccount = privateKeyToAccount(
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
);
const rpcUrl = "https://rpc.sepolia.org";
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const userWalletClient = createWalletClient({
account: userAccount,
chain: sepolia,
transport: http(rpcUrl),
});
const adminWalletClient = createWalletClient({
account: adminAccount,
chain: sepolia,
transport: http(rpcUrl),
});
const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});
const airdropAddress = "0xYourAirdropClone" as `0x${string}`;
const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient: userWalletClient,
address: airdropAddress,
});
// Admin encrypts BOUND TO THE RECIPIENT and signs (server-side in production).
const amount = 500_000n; // 0.5 tokens at 6 decimals
const encrypted = await encryptUint64({
encryptor,
contractAddress: airdropAddress,
userAddress: userAccount.address, // recipient — NOT the admin
value: amount,
});
const signature = await signClaimAuthorization({
walletClient: adminWalletClient,
airdropAddress,
recipient: userAccount.address,
encryptedAmountHandle: encrypted.handle,
});
// getClaimAmount() submits a tx that grants caller decrypt access.
// Returns { handle, hash } — handle is the euint64 the Zama relayer can decrypt.
// Do NOT call speculatively — it costs gas and ACL is append-only.
const { handle, hash } = await airdrop.getClaimAmount({
signature,
encryptedInput: encrypted,
});
console.log("ACL grant tx:", hash);
console.log("Encrypted handle (euint64):", handle);
// Pass handle to the Zama relayer's userDecrypt to obtain the plaintext amount.
// See https://docs.zama.ai/fhevm for the full userDecrypt flow.
}
export { main };