Confidential Airdrop
@tokenops/sdk/fhe-airdrop is a typed wrapper for the ConfidentialAirdrop contract system. The factory deploys per-campaign LibClone instances; each clone holds one airdrop campaign for one ERC-7984 confidential token.
Claim amounts are kept confidential on-chain as euint64 ciphertexts. The admin encrypts the allocation bound to the recipient address, signs the resulting handle with EIP-712, and delivers the { encryptedInput, signature } pair to the recipient, who submits it as-is.
Sepolia factory: 0xbE6A3B78B36684fFee48De77d47Bc3393F5Acd4c, resolved automatically from publicClient.chain?.id. Mainnet slot is null; pass address explicitly on mainnet.
Claim flow
The admin/recipient handoff is type-enforced: the admin signs over the plaintext amount after encrypting it; the recipient submits { signature, encryptedInput } without re-encrypting (re-encrypting would produce a different handle and revert the signature).
Admin (server) User (wallet)
──────────────────────────────────────────────────────────────────
1. encryptUint64(amount)
→ { handle, inputProof }
2. signClaimAuthorization(
airdropAddress,
recipient,
handle
) → signature
3. Deliver (handle, inputProof, signature) to user
4. airdrop.claim({
signature,
encryptedInput: { handle, inputProof }
})
+ msg.value = GAS_FEE()
5. Contract verifies EIP-712 signature
Then: FHE.fromExternal(handle, inputProof) → confidentialTransfer
The claim signature commits to (airdropAddress, chainId, recipient, encryptedAmountHandle) via EIP-712. Each signature is single-use — the contract stores the struct hash and rejects replays.
30-second example
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,
createConfidentialAirdropClient,
encryptUint64,
signClaimAuthorization,
} from "@tokenops/sdk/fhe-airdrop";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
const rpcUrl = process.env.RPC_URL!;
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });
const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});
// ── Admin: create an airdrop ──────────────────────────────────────────────────
const factory = createConfidentialAirdropFactoryClient({ publicClient, walletClient, encryptor });
const now = Math.floor(Date.now() / 1000);
const { airdrop: airdropAddress } = await factory.createConfidentialAirdrop({
params: {
token: process.env.TOKEN as `0x${string}`,
startTimestamp: now + 60,
endTimestamp: now + 30 * 86400,
canExtendClaimWindow: false,
admin: account.address,
},
userSalt: "0x0000000000000000000000000000000000000000000000000000000000000001",
});
// ── Admin: issue a claim authorization for a recipient ────────────────────────
const recipient = "0xRecipientAddress" as `0x${string}`;
const amount = 1_000_000n; // 1 token at 6 decimals
// Bind the input proof to the RECIPIENT
const encrypted = await encryptUint64({
encryptor,
contractAddress: airdropAddress,
userAddress: recipient,
value: amount,
});
const signature = await signClaimAuthorization({
walletClient,
airdropAddress,
recipient,
encryptedAmountHandle: encrypted.handle,
});
// Deliver { encryptedInput: encrypted, signature } to the recipient
// ── User: claim ───────────────────────────────────────────────────────────────
const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient,
address: airdropAddress,
});
// GAS_FEE() is fetched and sent as msg.value automatically.
await airdrop.claim({ signature, encryptedInput: encrypted });
Frontend usage
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";
import { useAccount, useWalletClient } from "wagmi";
import { createConfidentialAirdropClient } from "@tokenops/sdk/fhe-airdrop";
const publicClient = createPublicClient({ chain: sepolia, transport: http() });
export function ClaimButton({
airdropAddress,
claimPayload,
}: {
airdropAddress: `0x${string}`;
claimPayload: {
encryptedInput: { handle: `0x${string}`; inputProof: `0x${string}` };
signature: `0x${string}`;
};
}) {
const { address: caller } = useAccount();
const { data: walletClient } = useWalletClient();
async function handleClaim() {
if (!walletClient || !caller) return;
const airdrop = createConfidentialAirdropClient({ publicClient, walletClient, address: airdropAddress });
const valid = await airdrop.isSignatureValid({
encryptedAmountHandle: claimPayload.encryptedInput.handle,
signature: claimPayload.signature,
caller,
});
if (!valid) throw new Error("Claim is no longer valid");
await airdrop.claim({
signature: claimPayload.signature,
encryptedInput: claimPayload.encryptedInput,
});
}
return <button onClick={handleClaim}>Claim tokens</button>;
}
Backend usage
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";
const account = privateKeyToAccount(process.env.OPERATOR_PRIVATE_KEY as `0x${string}`);
const rpcUrl = process.env.RPC_URL!;
const publicClient = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: sepolia, transport: http(rpcUrl) });
const encryptor = new RelayerNode({
transports: { [SepoliaConfig.chainId]: { ...SepoliaConfig, network: rpcUrl } },
getChainId: () => Promise.resolve(sepolia.id),
});
const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient,
address: process.env.AIRDROP_ADDRESS as `0x${string}`,
});
async function issueClaimPayload(recipient: `0x${string}`, amount: bigint) {
const encrypted = await encryptUint64({
encryptor,
contractAddress: airdrop.address,
userAddress: recipient,
value: amount,
});
const signature = await signClaimAuthorization({
walletClient,
airdropAddress: airdrop.address,
recipient,
encryptedAmountHandle: encrypted.handle,
});
return { encryptedInput: encrypted, signature };
}
// Pause claims on security incident
await airdrop.setPaused(true);
// Extend the claim window (requires canExtendClaimWindow = true at deploy time)
await airdrop.extendClaimWindow(Math.floor(Date.now() / 1000) + 60 * 86400);
// Withdraw remaining tokens after the campaign ends
await airdrop.withdraw(account.address);
Address override
On Sepolia, omit address on the factory client. On mainnet or any other chain, pass address explicitly. The clone client always needs address.
encryptor is required on the factory for createAndFundConfidentialAirdrop / fundConfidentialAirdrop. The clone client does not take an encryptor.
FHE pitfalls
getClaimAmount is a write transaction, not a free view. It submits a tx that calls FHE.allow(handle, msg.sender) on-chain. The handle is extracted from the receipt's ACL Allowed event — never from simulateContract.
ACL grants are append-only. Once getClaimAmount grants a handle to an address, that access cannot be revoked. Do not call it speculatively.
euint64 overflow. Plaintext amount values must fit in uint64. The SDK validates and throws if the value overflows.
Encryptor setup
See Getting Started for RelayerNode, RelayerWeb, mock encryptor, and lazy vs eager injection patterns.