Recipe · Operations · Airdrop

Off-chain admin signer for airdrop claims

Airdrops separate the keys that hold money (the funder) from the keys that authorize redemption (the EIP-712 signer). Sign on a cold / server key; recipients submit warm.

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
ts
// 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
tsx
// 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.

See also