The threat model#
Anyone with the admin private key can authorize any recipient for any amount. Splitting that key from the operator's warm wallet gives you defense-in-depth: warm-wallet compromise lets an attacker send funds out, but not authorize new claims.
The clone's EIP-712 domain is baked into immutable args at deploy. The signer just needs to recover to a holder of DEFAULT_ADMIN_ROLE at the time the recipient submits.
1. Sign on the server#
signClaimAuthorization from @tokenops/sdk/fhe-airdrop is a pure function, give it a viem WalletClient + the recipient + the encrypted amount handle, get back a 65-byte signature.
// server/sign-claim.ts, runs in a server action or Node worker.
import { createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { sepolia } from "viem/chains";
import { signClaimAuthorization } from "@tokenops/sdk/fhe-airdrop";
const admin = privateKeyToAccount(process.env.AIRDROP_ADMIN_PK as `0x${string}`);
const walletClient = createWalletClient({
account: admin,
chain: sepolia, // EIP-712 domain binds to walletClient.chain.id
transport: http(process.env.RPC_URL),
});
// encryptedInput ({ handle, inputProof }) comes from the admin-side encryptor
// (encryptUint64). Replay protection is on chain: the clone stores the struct
// hash of (recipient, encryptedAmount), so each signed handle is single-use.
export async function buildClaimAuthorization({
recipient,
encryptedInput,
}: {
recipient: `0x${string}`;
encryptedInput: { handle: `0x${string}`; inputProof: `0x${string}` };
}) {
const signature = await signClaimAuthorization({
walletClient,
airdropAddress,
recipient,
encryptedAmountHandle: encryptedInput.handle,
});
return { recipient, encryptedInput, signature };
}2. Submit on the client#
The recipient page fetches the signature bundle from your server action / API and submits via useClaim— same TanStack Query envelope as any other write hook.
// components/ClaimButton.tsx
const claim = useClaim({ address: airdropAddress });
// Pull the signature bundle from your server action / API.
const auth = await fetch("/api/sign-claim?recipient=" + address).then((r) => r.json());
claim.mutate({
encryptedInput: auth.encryptedInput,
signature: auth.signature,
});Pre-flight before submitting#
Use useAirdropIsSignatureValid + useAirdropIsSignatureClaimed as a free off-chain check before the recipient pays gas. Both are fast reads; they catch typos in handle / signature shape long before the wallet modal opens.