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 CLAIM_AUTHORIZER_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 Account + the recipient + encrypted amount + nonce, get back a 65-byte signature.

server/sign-claim.ts
ts
// server/sign-claim.ts, runs in a server action or Node worker.
import { privateKeyToAccount } from "viem/accounts";
import { signClaimAuthorization } from "@tokenops/sdk/fhe-airdrop";

const admin = privateKeyToAccount(process.env.AIRDROP_ADMIN_PK as `0x${string}`);

// nonce uniqueness is the operator's responsibility, use a monotonic counter
// per recipient or a UUID-derived bytes32.
export async function buildClaimAuthorization({
  recipient,
  encryptedAmount,
  nonce,
}: {
  recipient: `0x${string}`;
  encryptedAmount: `0x${string}`;
  nonce: `0x${string}`;
}) {
  const signature = await signClaimAuthorization({
    account: admin,
    publicClient,                // for chainId
    airdropAddress,
    recipient,
    encryptedAmountHandle: encryptedAmount,
    nonce,
  });
  return { recipient, encryptedAmount, nonce, signature };
}

2. Submit on the client#

The recipient page fetches the signature bundle from your server action / API and submits via useAirdropClaim— same TanStack Query envelope as any other write hook.

components/ClaimButton.tsx
tsx
// components/ClaimButton.tsx
const claim = useAirdropClaim({ 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({
  recipient: auth.recipient,
  encryptedAmount: auth.encryptedAmount,
  signature: auth.signature,
  nonce: auth.nonce,
});

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 nonce / signature shape long before the wallet modal opens.

See also