Skip to main content

@tokenops/sdk/fhe-airdrop

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 (server-side) 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.

Factory deployments: Sepolia 0xbE6A3B78B36684fFee48De77d47Bc3393F5Acd4c — resolved automatically from publicClient.chain?.id. Mainnet slot is still null; pass address explicitly on mainnet or any other chain. See address override below.

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,
signClaimAuthorization,
encryptUint64,
} 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 ──────────────────────────────────────────────────
// On Sepolia the factory address auto-resolves from `DEPLOYED_ADDRESSES`; omit
// `address` to use it. Pass `address` explicitly on mainnet (slot is `null`
// today) or any custom chain.
const factory = createConfidentialAirdropFactoryClient({
publicClient,
walletClient,
encryptor,
});

const now = Math.floor(Date.now() / 1000);
const userSalt = "0x0000000000000000000000000000000000000000000000000000000000000001" as const;
const params = {
token: process.env.TOKEN as `0x${string}`,
startTimestamp: now + 60,
endTimestamp: now + 30 * 86400,
canExtendClaimWindow: false,
admin: account.address,
};

// Deploy and read the clone address from the receipt in one call. Mirrors
// fhe-vesting's createManager — factory.createConfidentialAirdrop returns
// the rich { hash, airdrop } shape parsed from the ConfidentialAirdropCreated
// event. No raw Promise<Hex> variant; the receipt parse is always done for you.
const { airdrop: airdropAddress } = await factory.createConfidentialAirdrop({
params,
userSalt,
});

// Alternative: predict before mining. Unlike fhe-vesting, the airdrop factory
// does NOT pack `block.number` into the clone's immutable args, so the
// predicted address is stable across blocks for a given (params, userSalt,
// deployer, gasFee). Use this when you need the address *before* the tx mines.
//
// const gasFee = await factory.defaultGasFee();
// const predicted = await factory.predictAirdropAddress({
// params, userSalt, deployer: account.address, gasFee,
// });
// const { hash } = await factory.createConfidentialAirdrop({ params, userSalt });

// ── Admin: issue a claim authorization for a recipient ────────────────────────
const recipient = "0x0000000000000000000000000000000000000001" as `0x${string}`;
const amount = 1_000_000n; // 1 token at 6 decimals (ERC-7984 uses 6 decimals)

// Bind the input proof to the RECIPIENT — Zama proofs commit to
// (contractAddress, userAddress) at encrypt time, and the contract's
// `FHE.fromExternal(handle, proof)` rejects proofs bound to any other address.
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 ───────────────────────────────────────────────────────────────
// The recipient's client never re-encrypts — the signature commits to the
// specific handle from above, so the SDK submits the admin-issued pair verbatim.
const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient,
address: airdropAddress,
});

// GAS_FEE() is fetched and sent as msg.value automatically.
// `ClaimArgs` carries only the admin's `encryptedInput` and `signature` —
// there is no plaintext `amount` field. The recipient submits the admin's
// handle verbatim; re-encrypting would produce a different handle and the
// EIP-712 signature would revert.
const claimHash = await airdrop.claim({
signature,
encryptedInput: encrypted,
});

Claim flow

The admin signs over the plaintext amount after encrypting it into encryptedInput; the recipient submits { signature, encryptedInput }. ClaimArgs has no amount field — re-encrypting client-side would produce a different handle and the EIP-712 signature would revert.

Admin (server) User (wallet)
──────────────────────────────────────────────────────────────────
1. encryptUint64(amount)
→ { handle, inputProof }

2. signClaimAuthorization(
airdropAddress,
recipient,
handle
) → signature

3. Return (handle, inputProof, signature) to user
via API / email / merkle proof. `amount` is
plaintext context for the recipient's UI; it
does NOT travel on-chain or into the claim args.

4. airdrop.claim({
signature,
encryptedInput: { handle, inputProof }
})
sends: (handle, inputProof, signature)
+ msg.value = GAS_FEE()

5. Contract verifies:
- msg.value == GAS_FEE()
- EIP-712 signature over Claim(recipient, handle) is signed by DEFAULT_ADMIN_ROLE
- Signature not already consumed (replay protection)
Then: FHE.fromExternal(handle, inputProof) → confidentialTransfer(recipient, amount)

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.

Address override

On Sepolia, omit address on the factory client — it resolves from publicClient.chain?.id via DEPLOYED_ADDRESSES.fheAirdrop.confidentialAirdropFactory. On mainnet (slot null today) or any other chain, pass address explicitly. The clone client always needs address (per-campaign clones have no chain-level default).

encryptor is required on the factory for createAndFundConfidentialAirdrop / fundConfidentialAirdrop — pass it at construction (eager) or as a () => Encryptor | undefined factory (lazy, for React contexts). The clone client does NOT take an encryptor: claim / getClaimAmount submit the admin-issued { encryptedInput, signature } pair verbatim — they never re-encrypt on the recipient side.

// Factory — address omitted on Sepolia, required elsewhere.
const factory = createConfidentialAirdropFactoryClient({
publicClient,
walletClient,
encryptor, // required for fund methods
});

// Clone — address always required, no encryptor.
const airdrop = createConfidentialAirdropClient({
publicClient,
walletClient,
address: "0xYourCloneAddress",
});

The factory constructor throws DeploymentAddressUnavailableError (code: "TOKENOPS_DEPLOYMENT_ADDRESS_UNAVAILABLE") when no address is resolvable; error.context.reason distinguishes "chain-id-missing" from "registry-unknown-chain" / "registry-not-deployed" for UI branching.